aboutsummaryrefslogtreecommitdiff
path: root/modern
diff options
context:
space:
mode:
Diffstat (limited to 'modern')
-rw-r--r--modern/src/common/attributes/useServerAttributes.js8
-rw-r--r--modern/src/login/LoginPage.jsx24
-rw-r--r--modern/src/login/RegisterPage.jsx28
-rw-r--r--modern/src/resources/l10n/en.json4
-rw-r--r--modern/src/settings/UserPage.jsx60
5 files changed, 107 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..6cca2837 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,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());
}
@@ -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);
}
};
@@ -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>
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}
/>