aboutsummaryrefslogtreecommitdiff
path: root/src/settings/UserPage.jsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/settings/UserPage.jsx')
-rw-r--r--src/settings/UserPage.jsx428
1 files changed, 428 insertions, 0 deletions
diff --git a/src/settings/UserPage.jsx b/src/settings/UserPage.jsx
new file mode 100644
index 00000000..6748dd31
--- /dev/null
+++ b/src/settings/UserPage.jsx
@@ -0,0 +1,428 @@
+import React, { useEffect, useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ FormControlLabel,
+ Checkbox,
+ FormGroup,
+ TextField,
+ Button,
+ InputAdornment,
+ IconButton,
+ OutlinedInput,
+} from '@mui/material';
+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';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import useUserAttributes from '../common/attributes/useUserAttributes';
+import { sessionActions } from '../store';
+import SelectField from '../common/components/SelectField';
+import SettingsMenu from './components/SettingsMenu';
+import useCommonUserAttributes from '../common/attributes/useCommonUserAttributes';
+import { useAdministrator, useRestriction, useManager } from '../common/util/permissions';
+import useQuery from '../common/util/useQuery';
+import { useCatch } from '../reactHelper';
+import useMapStyles from '../map/core/useMapStyles';
+import { map } from '../map/core/MapView';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const UserPage = () => {
+ const classes = useSettingsStyles();
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const admin = useAdministrator();
+ const manager = useManager();
+ const fixedEmail = useRestriction('fixedEmail');
+
+ 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);
+ const userAttributes = useUserAttributes(t);
+
+ const { id } = useParams();
+ const [item, setItem] = useState(id === currentUser.id.toString() ? currentUser : null);
+
+ const [deleteEmail, setDeleteEmail] = useState();
+ const [deleteFailed, setDeleteFailed] = useState(false);
+
+ const handleDelete = useCatch(async () => {
+ if (deleteEmail === currentUser.email) {
+ setDeleteFailed(false);
+ const response = await fetch(`/api/users/${currentUser.id}`, { method: 'DELETE' });
+ if (response.ok) {
+ navigate('/login');
+ dispatch(sessionActions.updateUser(null));
+ } else {
+ throw Error(await response.text());
+ }
+ } else {
+ setDeleteFailed(true);
+ }
+ });
+
+ 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');
+
+ useEffect(() => {
+ if (!queryHandled && item && attribute) {
+ if (!item.attributes.hasOwnProperty('attribute')) {
+ const updatedAttributes = { ...item.attributes };
+ updatedAttributes[attribute] = '';
+ setItem({ ...item, attributes: updatedAttributes });
+ }
+ setQueryHandled(true);
+ }
+ }, [item, queryHandled, setQueryHandled, attribute]);
+
+ const onItemSaved = (result) => {
+ if (result.id === currentUser.id) {
+ dispatch(sessionActions.updateUser(result));
+ }
+ };
+
+ const validate = () => item && item.name && item.email && (item.id || item.password) && (admin || !totpForce || item.totpKey);
+
+ return (
+ <EditItemView
+ endpoint="users"
+ item={item}
+ setItem={setItem}
+ defaultItem={admin ? { deviceLimit: -1 } : {}}
+ validate={validate}
+ onItemSaved={onItemSaved}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'settingsUser']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded={!attribute}>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.name || ''}
+ onChange={(e) => setItem({ ...item, name: e.target.value })}
+ label={t('sharedName')}
+ />
+ <TextField
+ value={item.email || ''}
+ onChange={(e) => setItem({ ...item, email: e.target.value })}
+ label={t('userEmail')}
+ disabled={fixedEmail}
+ />
+ {!openIdForced && (
+ <TextField
+ type="password"
+ 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>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedPreferences')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.phone || ''}
+ onChange={(e) => setItem({ ...item, phone: e.target.value })}
+ label={t('sharedPhone')}
+ />
+ <FormControl>
+ <InputLabel>{t('mapDefault')}</InputLabel>
+ <Select
+ label={t('mapDefault')}
+ value={item.map || 'locationIqStreets'}
+ onChange={(e) => setItem({ ...item, map: e.target.value })}
+ >
+ {mapStyles.filter((style) => style.available).map((style) => (
+ <MenuItem key={style.id} value={style.id}>
+ <Typography component="span">{style.title}</Typography>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('settingsCoordinateFormat')}</InputLabel>
+ <Select
+ label={t('settingsCoordinateFormat')}
+ value={item.coordinateFormat || 'dd'}
+ onChange={(e) => setItem({ ...item, coordinateFormat: e.target.value })}
+ >
+ <MenuItem value="dd">{t('sharedDecimalDegrees')}</MenuItem>
+ <MenuItem value="ddm">{t('sharedDegreesDecimalMinutes')}</MenuItem>
+ <MenuItem value="dms">{t('sharedDegreesMinutesSeconds')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('settingsSpeedUnit')}</InputLabel>
+ <Select
+ label={t('settingsSpeedUnit')}
+ value={(item.attributes && item.attributes.speedUnit) || 'kn'}
+ onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, speedUnit: e.target.value } })}
+ >
+ <MenuItem value="kn">{t('sharedKn')}</MenuItem>
+ <MenuItem value="kmh">{t('sharedKmh')}</MenuItem>
+ <MenuItem value="mph">{t('sharedMph')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('settingsDistanceUnit')}</InputLabel>
+ <Select
+ label={t('settingsDistanceUnit')}
+ value={(item.attributes && item.attributes.distanceUnit) || 'km'}
+ onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, distanceUnit: e.target.value } })}
+ >
+ <MenuItem value="km">{t('sharedKm')}</MenuItem>
+ <MenuItem value="mi">{t('sharedMi')}</MenuItem>
+ <MenuItem value="nmi">{t('sharedNmi')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('settingsAltitudeUnit')}</InputLabel>
+ <Select
+ label={t('settingsAltitudeUnit')}
+ value={(item.attributes && item.attributes.altitudeUnit) || 'm'}
+ onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, altitudeUnit: e.target.value } })}
+ >
+ <MenuItem value="m">{t('sharedMeters')}</MenuItem>
+ <MenuItem value="ft">{t('sharedFeet')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('settingsVolumeUnit')}</InputLabel>
+ <Select
+ label={t('settingsVolumeUnit')}
+ value={(item.attributes && item.attributes.volumeUnit) || 'ltr'}
+ onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, volumeUnit: e.target.value } })}
+ >
+ <MenuItem value="ltr">{t('sharedLiter')}</MenuItem>
+ <MenuItem value="usGal">{t('sharedUsGallon')}</MenuItem>
+ <MenuItem value="impGal">{t('sharedImpGallon')}</MenuItem>
+ </Select>
+ </FormControl>
+ <SelectField
+ value={item.attributes && item.attributes.timezone}
+ onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, timezone: e.target.value } })}
+ endpoint="/api/server/timezones"
+ keyGetter={(it) => it}
+ titleGetter={(it) => it}
+ label={t('sharedTimezone')}
+ />
+ <TextField
+ value={item.poiLayer || ''}
+ onChange={(e) => setItem({ ...item, poiLayer: e.target.value })}
+ label={t('mapPoiLayer')}
+ />
+ <FormGroup>
+ <FormControlLabel
+ control={<Checkbox checked={item.twelveHourFormat} onChange={(e) => setItem({ ...item, twelveHourFormat: e.target.checked })} />}
+ label={t('settingsTwelveHourFormat')}
+ />
+ </FormGroup>
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedLocation')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ type="number"
+ value={item.latitude || 0}
+ onChange={(e) => setItem({ ...item, latitude: Number(e.target.value) })}
+ label={t('positionLatitude')}
+ />
+ <TextField
+ type="number"
+ value={item.longitude || 0}
+ onChange={(e) => setItem({ ...item, longitude: Number(e.target.value) })}
+ label={t('positionLongitude')}
+ />
+ <TextField
+ type="number"
+ value={item.zoom || 0}
+ onChange={(e) => setItem({ ...item, zoom: Number(e.target.value) })}
+ label={t('serverZoom')}
+ />
+ <Button
+ variant="outlined"
+ color="primary"
+ onClick={() => {
+ const { lng, lat } = map.getCenter();
+ setItem({
+ ...item,
+ latitude: Number(lat.toFixed(6)),
+ longitude: Number(lng.toFixed(6)),
+ zoom: Number(map.getZoom().toFixed(1)),
+ });
+ }}
+ >
+ {t('mapCurrentLocation')}
+ </Button>
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedPermissions')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ label={t('userExpirationTime')}
+ type="date"
+ value={(item.expirationTime && dayjs(item.expirationTime).locale('en').format('YYYY-MM-DD')) || '2099-01-01'}
+ onChange={(e) => setItem({ ...item, expirationTime: dayjs(e.target.value, 'YYYY-MM-DD').locale('en').format() })}
+ disabled={!manager}
+ />
+ <TextField
+ type="number"
+ value={item.deviceLimit || 0}
+ onChange={(e) => setItem({ ...item, deviceLimit: Number(e.target.value) })}
+ label={t('userDeviceLimit')}
+ disabled={!admin}
+ />
+ <TextField
+ type="number"
+ value={item.userLimit || 0}
+ onChange={(e) => setItem({ ...item, userLimit: Number(e.target.value) })}
+ label={t('userUserLimit')}
+ disabled={!admin}
+ />
+ <FormGroup>
+ <FormControlLabel
+ control={<Checkbox checked={item.disabled} onChange={(e) => setItem({ ...item, disabled: e.target.checked })} />}
+ label={t('sharedDisabled')}
+ disabled={!manager}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.administrator} onChange={(e) => setItem({ ...item, administrator: e.target.checked })} />}
+ label={t('userAdmin')}
+ disabled={!admin}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.readonly} onChange={(e) => setItem({ ...item, readonly: e.target.checked })} />}
+ label={t('serverReadonly')}
+ disabled={!manager}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.deviceReadonly} onChange={(e) => setItem({ ...item, deviceReadonly: e.target.checked })} />}
+ label={t('userDeviceReadonly')}
+ disabled={!manager}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.limitCommands} onChange={(e) => setItem({ ...item, limitCommands: e.target.checked })} />}
+ label={t('userLimitCommands')}
+ disabled={!manager}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.disableReports} onChange={(e) => setItem({ ...item, disableReports: e.target.checked })} />}
+ label={t('userDisableReports')}
+ disabled={!manager}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.fixedEmail} onChange={(e) => setItem({ ...item, fixedEmail: e.target.checked })} />}
+ label={t('userFixedEmail')}
+ disabled={!manager}
+ />
+ </FormGroup>
+ </AccordionDetails>
+ </Accordion>
+ <EditAttributesAccordion
+ attribute={attribute}
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={{ ...commonUserAttributes, ...userAttributes }}
+ focusAttribute={attribute}
+ />
+ {registrationEnabled && item.id === currentUser.id && !manager && (
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1" color="error">
+ {t('userDeleteAccount')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={deleteEmail}
+ onChange={(e) => setDeleteEmail(e.target.value)}
+ label={t('userEmail')}
+ error={deleteFailed}
+ />
+ <Button
+ variant="outlined"
+ color="error"
+ onClick={handleDelete}
+ startIcon={<DeleteForeverIcon />}
+ >
+ {t('userDeleteAccount')}
+ </Button>
+ </AccordionDetails>
+ </Accordion>
+ )}
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default UserPage;