diff options
-rw-r--r-- | modern/src/common/attributes/useServerAttributes.js | 8 | ||||
-rw-r--r-- | modern/src/login/LoginPage.jsx | 25 | ||||
-rw-r--r-- | modern/src/login/RegisterPage.jsx | 28 | ||||
-rw-r--r-- | modern/src/resources/l10n/en.json | 4 | ||||
-rw-r--r-- | modern/src/settings/UserPage.jsx | 60 |
5 files changed, 108 insertions, 17 deletions
diff --git a/modern/src/common/attributes/useServerAttributes.js b/modern/src/common/attributes/useServerAttributes.js index 5cce479e..4339840e 100644 --- a/modern/src/common/attributes/useServerAttributes.js +++ b/modern/src/common/attributes/useServerAttributes.js @@ -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', diff --git a/modern/src/login/LoginPage.jsx b/modern/src/login/LoginPage.jsx index 73104def..e196a18b 100644 --- a/modern/src/login/LoginPage.jsx +++ b/modern/src/login/LoginPage.jsx @@ -57,6 +57,7 @@ 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']); @@ -64,6 +65,7 @@ const LoginPage = () => { 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); @@ -89,16 +91,21 @@ 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), }); + console.log(response); // TODO check missing code 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()); } @@ -120,7 +127,7 @@ const LoginPage = () => { }); const handleSpecialKey = (e) => { - if (e.keyCode === 13 && email && password) { + if (e.keyCode === 13 && email && password && (!codeEnabled || code)) { handlePasswordLogin(e); } }; @@ -179,12 +186,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> diff --git a/modern/src/login/RegisterPage.jsx b/modern/src/login/RegisterPage.jsx index 6dfe40a4..1ec791a1 100644 --- a/modern/src/login/RegisterPage.jsx +++ b/modern/src/login/RegisterPage.jsx @@ -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) => ({ @@ -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); @@ -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" diff --git a/modern/src/resources/l10n/en.json b/modern/src/resources/l10n/en.json index be919504..ed9516b5 100644 --- a/modern/src/resources/l10n/en.json +++ b/modern/src/resources/l10n/en.json @@ -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", @@ -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", diff --git a/modern/src/settings/UserPage.jsx b/modern/src/settings/UserPage.jsx index 939859ae..d9a608ef 100644 --- a/modern/src/settings/UserPage.jsx +++ b/modern/src/settings/UserPage.jsx @@ -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'; @@ -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); @@ -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'); @@ -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 @@ -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> @@ -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> @@ -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> @@ -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> @@ -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 @@ -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} /> |