Skip to content

Commit

Permalink
Merge pull request #1187 from e-macgregor/master
Browse files Browse the repository at this point in the history
totp
  • Loading branch information
tananaev authored Oct 29, 2023
2 parents 242c434 + e4e1e3f commit 6a368f7
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 17 deletions.
8 changes: 8 additions & 0 deletions modern/src/common/attributes/useServerAttributes.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,14 @@ export default (t) => useMemo(() => ({
name: t('settingsDarkMode'),
type: 'boolean',
},
totpEnable: {
name: t('settingsTotpEnable'),
type: 'boolean',
},
totpForce: {
name: t('settingsTotpForce'),
type: 'boolean',
},
'ui.disableLoginLanguage': {
name: t('attributeUiDisableLoginLanguage'),
type: 'boolean',
Expand Down
24 changes: 21 additions & 3 deletions modern/src/login/LoginPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,13 +57,15 @@ const LoginPage = () => {

const [email, setEmail] = usePersistedState('loginEmail', '');
const [password, setPassword] = useState('');
const [code, setCode] = useState('');

const registrationEnabled = useSelector((state) => state.session.server.registration);
const languageEnabled = useSelector((state) => !state.session.server.attributes['ui.disableLoginLanguage']);
const changeEnabled = useSelector((state) => !state.session.server.attributes.disableChange);
const emailEnabled = useSelector((state) => state.session.server.emailEnabled);
const openIdEnabled = useSelector((state) => state.session.server.openIdEnabled);
const openIdForced = useSelector((state) => state.session.server.openIdEnabled && state.session.server.openIdForce);
const [codeEnabled, setCodeEnabled] = useState(false);

const [announcementShown, setAnnouncementShown] = useState(false);
const announcement = useSelector((state) => state.session.server.announcement);
Expand All @@ -89,16 +91,20 @@ const LoginPage = () => {

const handlePasswordLogin = async (event) => {
event.preventDefault();
setFailed(false);
try {
const query = `email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`;
const response = await fetch('/api/session', {
method: 'POST',
body: new URLSearchParams(`email=${encodeURIComponent(email)}&password=${encodeURIComponent(password)}`),
body: new URLSearchParams(code.length ? query + `&code=${code}` : query),
});
if (response.ok) {
const user = await response.json();
generateLoginToken();
dispatch(sessionActions.updateUser(user));
navigate('/');
} else if (response.status === 401 && response.headers.get('WWW-Authenticate') === 'TOTP') {
setCodeEnabled(true);
} else {
throw Error(await response.text());
}
Expand All @@ -120,7 +126,7 @@ const LoginPage = () => {
});

const handleSpecialKey = (e) => {
if (e.keyCode === 13 && email && password) {
if (e.keyCode === 13 && email && password && (!codeEnabled || code)) {
handlePasswordLogin(e);
}
};
Expand Down Expand Up @@ -179,12 +185,24 @@ const LoginPage = () => {
onChange={(e) => setPassword(e.target.value)}
onKeyUp={handleSpecialKey}
/>
{codeEnabled && (
<TextField
required
error={failed}
label={t('loginTotpCode')}
name="code"
value={code}
type="number"
onChange={(e) => setCode(e.target.value)}
onKeyUp={handleSpecialKey}
/>
)}
<Button
onClick={handlePasswordLogin}
onKeyUp={handleSpecialKey}
variant="contained"
color="secondary"
disabled={!email || !password}
disabled={!email || !password || (codeEnabled && !code)}
>
{t('loginLogin')}
</Button>
Expand Down
28 changes: 26 additions & 2 deletions modern/src/login/RegisterPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import LoginLayout from './LoginLayout';
import { useTranslation } from '../common/components/LocalizationProvider';
import { snackBarDurationShortMs } from '../common/util/duration';
import { useCatch } from '../reactHelper';
import { useCatch, useEffectAsync } from '../reactHelper';
import { sessionActions } from '../store';

const useStyles = makeStyles((theme) => ({
Expand Down Expand Up @@ -37,17 +37,30 @@ const RegisterPage = () => {
const t = useTranslation();

const server = useSelector((state) => state.session.server);
const totpForce = useSelector((state) => state.session.server.attributes.totpForce);

const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [totpKey, setTotpKey] = useState('');
const [snackbarOpen, setSnackbarOpen] = useState(false);

useEffectAsync(async () => {
if (totpForce) {
const response = await fetch('/api/users/totp', { method: 'POST' });
if (response.ok) {
setTotpKey(await response.text());
} else {
throw Error(await response.text());
}
}
}, [totpForce, setTotpKey]);

const handleSubmit = useCatch(async () => {
const response = await fetch('/api/users', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, email, password }),
body: JSON.stringify({ name, email, password, totpKey }),
});
if (response.ok) {
setSnackbarOpen(true);
Expand Down Expand Up @@ -96,6 +109,17 @@ const RegisterPage = () => {
autoComplete="current-password"
onChange={(event) => setPassword(event.target.value)}
/>
{totpForce && (
<TextField
required
label={t('loginTotpKey')}
name="totpKey"
value={totpKey}
InputProps={{
readOnly: true,
}}
/>
)}
<Button
variant="contained"
color="secondary"
Expand Down
4 changes: 4 additions & 0 deletions modern/src/resources/l10n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,8 @@
"loginUpdateSuccess": "New password is set",
"loginLogout": "Logout",
"loginLogo": "Logo",
"loginTotpCode": "One-time Password Code",
"loginTotpKey": "One-time Password Key",
"devicesAndState": "Devices and State",
"deviceSelected": "Selected Device",
"deviceTitle": "Devices",
Expand Down Expand Up @@ -222,6 +224,8 @@
"settingsAppVersion": "App Version",
"settingsConnection": "Connection",
"settingsDarkMode": "Dark Mode",
"settingsTotpEnable": "Enable One-time Password",
"settingsTotpForce": "Force One-time Password",
"reportTitle": "Reports",
"reportScheduled": "Scheduled Reports",
"reportDevice": "Device",
Expand Down
60 changes: 48 additions & 12 deletions modern/src/settings/UserPage.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,15 @@ import {
FormGroup,
TextField,
Button,
InputAdornment,
IconButton,
OutlinedInput,
} from '@mui/material';
import makeStyles from '@mui/styles/makeStyles';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import DeleteForeverIcon from '@mui/icons-material/DeleteForever';
import CachedIcon from '@mui/icons-material/Cached';
import CloseIcon from '@mui/icons-material/Close';
import { useDispatch, useSelector } from 'react-redux';
import dayjs from 'dayjs';
import EditItemView from './components/EditItemView';
Expand Down Expand Up @@ -56,6 +61,8 @@ const UserPage = () => {
const currentUser = useSelector((state) => state.session.user);
const registrationEnabled = useSelector((state) => state.session.server.registration);
const openIdForced = useSelector((state) => state.session.server.openIdForce);
const totpEnable = useSelector((state) => state.session.server.attributes.totpEnable);
const totpForce = useSelector((state) => state.session.server.attributes.totpForce);

const mapStyles = useMapStyles();
const commonUserAttributes = useCommonUserAttributes(t);
Expand All @@ -82,6 +89,15 @@ const UserPage = () => {
}
});

const handleGenerateTotp = useCatch(async () => {
const response = await fetch('/api/users/totp', { method: 'POST' });
if (response.ok) {
setItem({ ...item, totpKey: await response.text() })
} else {
throw Error(await response.text());
}
});

const query = useQuery();
const [queryHandled, setQueryHandled] = useState(false);
const attribute = query.get('attribute');
Expand All @@ -103,7 +119,7 @@ const UserPage = () => {
}
};

const validate = () => item && item.name && item.email && (item.id || item.password);
const validate = () => item && item.name && item.email && (item.id || item.password) && (admin || !totpForce || item.totpKey);

return (
<EditItemView
Expand All @@ -127,22 +143,42 @@ const UserPage = () => {
<AccordionDetails className={classes.details}>
<TextField
value={item.name || ''}
onChange={(event) => setItem({ ...item, name: event.target.value })}
onChange={(e) => setItem({ ...item, name: e.target.value })}
label={t('sharedName')}
/>
<TextField
value={item.email || ''}
onChange={(event) => setItem({ ...item, email: event.target.value })}
onChange={(e) => setItem({ ...item, email: e.target.value })}
label={t('userEmail')}
disabled={fixedEmail}
/>
{!openIdForced && (
<TextField
type="password"
onChange={(event) => setItem({ ...item, password: event.target.value })}
onChange={(e) => setItem({ ...item, password: e.target.value })}
label={t('userPassword')}
/>
)}
{totpEnable && (
<FormControl>
<InputLabel>{t('loginTotpKey')}</InputLabel>
<OutlinedInput
readOnly
label={t('loginTotpKey')}
value={item.totpKey || ''}
endAdornment={(
<InputAdornment position="end">
<IconButton size="small" edge="end" onClick={handleGenerateTotp}>
<CachedIcon fontSize="small" />
</IconButton>
<IconButton size="small" edge="end" onClick={() => setItem({ ...item, totpKey: null })}>
<CloseIcon fontSize="small" />
</IconButton>
</InputAdornment>
)}
/>
</FormControl>
)}
</AccordionDetails>
</Accordion>
<Accordion>
Expand All @@ -154,7 +190,7 @@ const UserPage = () => {
<AccordionDetails className={classes.details}>
<TextField
value={item.phone || ''}
onChange={(event) => setItem({ ...item, phone: event.target.value })}
onChange={(e) => setItem({ ...item, phone: e.target.value })}
label={t('sharedPhone')}
/>
<FormControl>
Expand All @@ -176,7 +212,7 @@ const UserPage = () => {
<Select
label={t('settingsCoordinateFormat')}
value={item.coordinateFormat || 'dd'}
onChange={(event) => setItem({ ...item, coordinateFormat: event.target.value })}
onChange={(e) => setItem({ ...item, coordinateFormat: e.target.value })}
>
<MenuItem value="dd">{t('sharedDecimalDegrees')}</MenuItem>
<MenuItem value="ddm">{t('sharedDegreesDecimalMinutes')}</MenuItem>
Expand Down Expand Up @@ -241,12 +277,12 @@ const UserPage = () => {
/>
<TextField
value={item.poiLayer || ''}
onChange={(event) => setItem({ ...item, poiLayer: event.target.value })}
onChange={(e) => setItem({ ...item, poiLayer: e.target.value })}
label={t('mapPoiLayer')}
/>
<FormGroup>
<FormControlLabel
control={<Checkbox checked={item.twelveHourFormat} onChange={(event) => setItem({ ...item, twelveHourFormat: event.target.checked })} />}
control={<Checkbox checked={item.twelveHourFormat} onChange={(e) => setItem({ ...item, twelveHourFormat: e.target.checked })} />}
label={t('settingsTwelveHourFormat')}
/>
</FormGroup>
Expand All @@ -262,19 +298,19 @@ const UserPage = () => {
<TextField
type="number"
value={item.latitude || 0}
onChange={(event) => setItem({ ...item, latitude: Number(event.target.value) })}
onChange={(e) => setItem({ ...item, latitude: Number(e.target.value) })}
label={t('positionLatitude')}
/>
<TextField
type="number"
value={item.longitude || 0}
onChange={(event) => setItem({ ...item, longitude: Number(event.target.value) })}
onChange={(e) => setItem({ ...item, longitude: Number(e.target.value) })}
label={t('positionLongitude')}
/>
<TextField
type="number"
value={item.zoom || 0}
onChange={(event) => setItem({ ...item, zoom: Number(event.target.value) })}
onChange={(e) => setItem({ ...item, zoom: Number(e.target.value) })}
label={t('serverZoom')}
/>
<Button
Expand Down Expand Up @@ -378,7 +414,7 @@ const UserPage = () => {
<AccordionDetails className={classes.details}>
<TextField
value={deleteEmail}
onChange={(event) => setDeleteEmail(event.target.value)}
onChange={(e) => setDeleteEmail(e.target.value)}
label={t('userEmail')}
error={deleteFailed}
/>
Expand Down

0 comments on commit 6a368f7

Please sign in to comment.