aboutsummaryrefslogtreecommitdiff
path: root/src/settings
diff options
context:
space:
mode:
Diffstat (limited to 'src/settings')
-rw-r--r--src/settings/AccumulatorsPage.jsx107
-rw-r--r--src/settings/AnnouncementPage.jsx106
-rw-r--r--src/settings/CalendarPage.jsx208
-rw-r--r--src/settings/CalendarsPage.jsx64
-rw-r--r--src/settings/CommandDevicePage.jsx111
-rw-r--r--src/settings/CommandGroupPage.jsx105
-rw-r--r--src/settings/CommandPage.jsx50
-rw-r--r--src/settings/CommandsPage.jsx74
-rw-r--r--src/settings/ComputedAttributePage.jsx177
-rw-r--r--src/settings/ComputedAttributesPage.jsx74
-rw-r--r--src/settings/DeviceConnectionsPage.jsx107
-rw-r--r--src/settings/DevicePage.jsx176
-rw-r--r--src/settings/DevicesPage.jsx114
-rw-r--r--src/settings/DriverPage.jsx62
-rw-r--r--src/settings/DriversPage.jsx66
-rw-r--r--src/settings/GeofencePage.jsx88
-rw-r--r--src/settings/GroupConnectionsPage.jsx107
-rw-r--r--src/settings/GroupPage.jsx93
-rw-r--r--src/settings/GroupsPage.jsx91
-rw-r--r--src/settings/MaintenancePage.jsx174
-rw-r--r--src/settings/MaintenancesPage.jsx100
-rw-r--r--src/settings/NotificationPage.jsx144
-rw-r--r--src/settings/NotificationsPage.jsx83
-rw-r--r--src/settings/PreferencesPage.jsx375
-rw-r--r--src/settings/ServerPage.jsx316
-rw-r--r--src/settings/SharePage.jsx109
-rw-r--r--src/settings/UserConnectionsPage.jsx129
-rw-r--r--src/settings/UserPage.jsx428
-rw-r--r--src/settings/UsersPage.jsx130
-rw-r--r--src/settings/common/useSettingsStyles.js33
-rw-r--r--src/settings/components/AddAttributeDialog.jsx104
-rw-r--r--src/settings/components/BaseCommandView.jsx79
-rw-r--r--src/settings/components/CollectionActions.jsx104
-rw-r--r--src/settings/components/CollectionFab.jsx35
-rw-r--r--src/settings/components/EditAttributesAccordion.jsx217
-rw-r--r--src/settings/components/EditItemView.jsx101
-rw-r--r--src/settings/components/SearchHeader.jsx38
-rw-r--r--src/settings/components/SettingsMenu.jsx173
38 files changed, 4852 insertions, 0 deletions
diff --git a/src/settings/AccumulatorsPage.jsx b/src/settings/AccumulatorsPage.jsx
new file mode 100644
index 00000000..1c9b6e65
--- /dev/null
+++ b/src/settings/AccumulatorsPage.jsx
@@ -0,0 +1,107 @@
+import React, { useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+ TextField,
+ Button,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import { useCatch } from '../reactHelper';
+import { useAttributePreference } from '../common/util/preferences';
+import { distanceFromMeters, distanceToMeters, distanceUnitString } from '../common/util/converter';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const AccumulatorsPage = () => {
+ const navigate = useNavigate();
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const distanceUnit = useAttributePreference('distanceUnit');
+
+ const { deviceId } = useParams();
+ const position = useSelector((state) => state.session.positions[deviceId]);
+
+ const [item, setItem] = useState();
+
+ useEffect(() => {
+ if (position) {
+ setItem({
+ deviceId: parseInt(deviceId, 10),
+ hours: position.attributes.hours || 0,
+ totalDistance: position.attributes.totalDistance || 0,
+ });
+ }
+ }, [deviceId, position]);
+
+ const handleSave = useCatch(async () => {
+ const response = await fetch(`/api/devices/${deviceId}/accumulators`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(item),
+ });
+
+ if (response.ok) {
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['sharedDeviceAccumulators']}>
+ {item && (
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ type="number"
+ value={item.hours / 3600000}
+ onChange={(event) => setItem({ ...item, hours: Number(event.target.value) * 3600000 })}
+ label={t('positionHours')}
+ />
+ <TextField
+ type="number"
+ value={distanceFromMeters(item.totalDistance, distanceUnit)}
+ onChange={(event) => setItem({ ...item, totalDistance: distanceToMeters(Number(event.target.value), distanceUnit) })}
+ label={`${t('deviceTotalDistance')} (${distanceUnitString(distanceUnit, t)})`}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={handleSave}
+ >
+ {t('sharedSave')}
+ </Button>
+ </div>
+ </Container>
+ )}
+ </PageLayout>
+ );
+};
+
+export default AccumulatorsPage;
diff --git a/src/settings/AnnouncementPage.jsx b/src/settings/AnnouncementPage.jsx
new file mode 100644
index 00000000..39488f02
--- /dev/null
+++ b/src/settings/AnnouncementPage.jsx
@@ -0,0 +1,106 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+ TextField,
+ Button,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import { useCatchCallback } from '../reactHelper';
+import useSettingsStyles from './common/useSettingsStyles';
+import SelectField from '../common/components/SelectField';
+import { prefixString } from '../common/util/stringUtils';
+
+const AnnouncementPage = () => {
+ const navigate = useNavigate();
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [users, setUsers] = useState([]);
+ const [notificator, setNotificator] = useState();
+ const [message, setMessage] = useState({});
+
+ const handleSend = useCatchCallback(async () => {
+ const query = new URLSearchParams();
+ users.forEach((userId) => query.append('userId', userId));
+ const response = await fetch(`/api/notifications/send/${notificator}?${query.toString()}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(message),
+ });
+ if (response.ok) {
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ }, [users, notificator, message, navigate]);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['serverAnnouncement']}>
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ multiple
+ value={users}
+ onChange={(e) => setUsers(e.target.value)}
+ endpoint="/api/users"
+ label={t('settingsUsers')}
+ />
+ <SelectField
+ value={notificator}
+ onChange={(e) => setNotificator(e.target.value)}
+ endpoint="/api/notifications/notificators"
+ keyGetter={(it) => it.type}
+ titleGetter={(it) => t(prefixString('notificator', it.type))}
+ label={t('notificationNotificators')}
+ />
+ <TextField
+ value={message.subject}
+ onChange={(e) => setMessage({ ...message, subject: e.target.value })}
+ label={t('sharedSubject')}
+ />
+ <TextField
+ value={message.body}
+ onChange={(e) => setMessage({ ...message, body: e.target.value })}
+ label={t('commandMessage')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={handleSend}
+ disabled={!notificator || !message.subject || !message.body}
+ >
+ {t('commandSend')}
+ </Button>
+ </div>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default AnnouncementPage;
diff --git a/src/settings/CalendarPage.jsx b/src/settings/CalendarPage.jsx
new file mode 100644
index 00000000..8a3dc986
--- /dev/null
+++ b/src/settings/CalendarPage.jsx
@@ -0,0 +1,208 @@
+import dayjs from 'dayjs';
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import TextField from '@mui/material/TextField';
+import {
+ Accordion, AccordionSummary, AccordionDetails, Typography, FormControl, InputLabel, Select, MenuItem,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { DropzoneArea } from 'react-mui-dropzone';
+import EditItemView from './components/EditItemView';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import SettingsMenu from './components/SettingsMenu';
+import { prefixString } from '../common/util/stringUtils';
+import { calendarsActions } from '../store';
+import { useCatch } from '../reactHelper';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const formatCalendarTime = (time) => {
+ const tzid = Intl.DateTimeFormat().resolvedOptions().timeZone;
+ return `TZID=${tzid}:${time.locale('en').format('YYYYMMDDTHHmmss')}`;
+};
+
+const parseRule = (rule) => {
+ if (rule.endsWith('COUNT=1')) {
+ return { frequency: 'ONCE' };
+ }
+ const fragments = rule.split(';');
+ const frequency = fragments[0].substring(11);
+ const by = fragments.length > 1 ? fragments[1].split('=')[1].split(',') : null;
+ return { frequency, by };
+};
+
+const formatRule = (rule) => {
+ const by = rule.by && rule.by.join(',');
+ switch (rule.frequency) {
+ case 'DAILY':
+ return `RRULE:FREQ=${rule.frequency}`;
+ case 'WEEKLY':
+ return `RRULE:FREQ=${rule.frequency};BYDAY=${by || 'SU'}`;
+ case 'MONTHLY':
+ return `RRULE:FREQ=${rule.frequency};BYMONTHDAY=${by || 1}`;
+ default:
+ return 'RRULE:FREQ=DAILY;COUNT=1';
+ }
+};
+
+const updateCalendar = (lines, index, element) => window.btoa(lines.map((e, i) => (i !== index ? e : element)).join('\n'));
+
+const simpleCalendar = () => window.btoa([
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//Traccar//NONSGML Traccar//EN',
+ 'BEGIN:VEVENT',
+ 'UID:00000000-0000-0000-0000-000000000000',
+ `DTSTART;${formatCalendarTime(dayjs())}`,
+ `DTEND;${formatCalendarTime(dayjs().add(1, 'hours'))}`,
+ 'RRULE:FREQ=DAILY',
+ 'SUMMARY:Event',
+ 'END:VEVENT',
+ 'END:VCALENDAR',
+].join('\n'));
+
+const CalendarPage = () => {
+ const classes = useSettingsStyles();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const [item, setItem] = useState();
+
+ const decoded = item && item.data && window.atob(item.data);
+
+ const simple = decoded && decoded.indexOf('//Traccar//') > 0;
+
+ const lines = decoded && decoded.split('\n');
+
+ const rule = simple && parseRule(lines[7]);
+
+ const handleFiles = (files) => {
+ if (files.length > 0) {
+ const reader = new FileReader();
+ reader.onload = (event) => {
+ const { result } = event.target;
+ setItem({ ...item, data: result.substr(result.indexOf(',') + 1) });
+ };
+ reader.readAsDataURL(files[0]);
+ }
+ };
+
+ const onItemSaved = useCatch(async () => {
+ const response = await fetch('/api/calendars');
+ if (response.ok) {
+ dispatch(calendarsActions.refresh(await response.json()));
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const validate = () => item && item.name && item.data;
+
+ return (
+ <EditItemView
+ endpoint="calendars"
+ item={item}
+ setItem={setItem}
+ defaultItem={{ data: simpleCalendar() }}
+ validate={validate}
+ onItemSaved={onItemSaved}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedCalendar']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.name || ''}
+ onChange={(event) => setItem({ ...item, name: event.target.value })}
+ label={t('sharedName')}
+ />
+ <FormControl>
+ <InputLabel>{t('sharedType')}</InputLabel>
+ <Select
+ label={t('sharedType')}
+ value={simple ? 'simple' : 'custom'}
+ onChange={(e) => setItem({ ...item, data: (e.target.value === 'simple' ? simpleCalendar() : null) })}
+ >
+ <MenuItem value="simple">{t('calendarSimple')}</MenuItem>
+ <MenuItem value="custom">{t('reportCustom')}</MenuItem>
+ </Select>
+ </FormControl>
+ {simple ? (
+ <>
+ <TextField
+ label={t('reportFrom')}
+ type="datetime-local"
+ value={dayjs(lines[5].slice(-15)).locale('en').format('YYYY-MM-DDTHH:mm')}
+ onChange={(e) => {
+ const time = formatCalendarTime(dayjs(e.target.value, 'YYYY-MM-DDTHH:mm'));
+ setItem({ ...item, data: updateCalendar(lines, 5, `DTSTART;${time}`) });
+ }}
+ />
+ <TextField
+ label={t('reportTo')}
+ type="datetime-local"
+ value={dayjs(lines[6].slice(-15)).locale('en').format('YYYY-MM-DDTHH:mm')}
+ onChange={(e) => {
+ const time = formatCalendarTime(dayjs(e.target.value, 'YYYY-MM-DDTHH:mm'));
+ setItem({ ...item, data: updateCalendar(lines, 6, `DTEND;${time}`) });
+ }}
+ />
+ <FormControl>
+ <InputLabel>{t('calendarRecurrence')}</InputLabel>
+ <Select
+ label={t('calendarRecurrence')}
+ value={rule.frequency}
+ onChange={(e) => setItem({ ...item, data: updateCalendar(lines, 7, formatRule({ frequency: e.target.value })) })}
+ >
+ {['ONCE', 'DAILY', 'WEEKLY', 'MONTHLY'].map((it) => (
+ <MenuItem key={it} value={it}>{t(prefixString('calendar', it.toLowerCase()))}</MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ {['WEEKLY', 'MONTHLY'].includes(rule.frequency) && (
+ <FormControl>
+ <InputLabel>{t('calendarDays')}</InputLabel>
+ <Select
+ multiple
+ label={t('calendarDays')}
+ value={rule.by}
+ onChange={(e) => setItem({ ...item, data: updateCalendar(lines, 7, formatRule({ ...rule, by: e.target.value })) })}
+ >
+ {rule.frequency === 'WEEKLY' ? ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'].map((it) => (
+ <MenuItem key={it} value={it.substring(0, 2).toUpperCase()}>{t(prefixString('calendar', it))}</MenuItem>
+ )) : Array.from({ length: 31 }, (_, i) => i + 1).map((it) => (
+ <MenuItem key={it} value={it}>{it}</MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ )}
+ </>
+ ) : (
+ <DropzoneArea
+ dropzoneText={t('sharedDropzoneText')}
+ filesLimit={1}
+ onChange={handleFiles}
+ showAlerts={false}
+ />
+ )}
+ </AccordionDetails>
+ </Accordion>
+ <EditAttributesAccordion
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={{}}
+ />
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default CalendarPage;
diff --git a/src/settings/CalendarsPage.jsx b/src/settings/CalendarsPage.jsx
new file mode 100644
index 00000000..de27a451
--- /dev/null
+++ b/src/settings/CalendarsPage.jsx
@@ -0,0 +1,64 @@
+import React, { useState } from 'react';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const CalendarsPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/calendars');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedCalendars']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.name}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions itemId={item.id} editPath="/settings/calendar" endpoint="calendars" setTimestamp={setTimestamp} />
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={2} endAction />)}
+ </TableBody>
+ </Table>
+ <CollectionFab editPath="/settings/calendar" />
+ </PageLayout>
+ );
+};
+
+export default CalendarsPage;
diff --git a/src/settings/CommandDevicePage.jsx b/src/settings/CommandDevicePage.jsx
new file mode 100644
index 00000000..b3144cd0
--- /dev/null
+++ b/src/settings/CommandDevicePage.jsx
@@ -0,0 +1,111 @@
+import React, { useState } from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+ Button,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import BaseCommandView from './components/BaseCommandView';
+import SelectField from '../common/components/SelectField';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import { useCatch } from '../reactHelper';
+import { useRestriction } from '../common/util/permissions';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const CommandDevicePage = () => {
+ const navigate = useNavigate();
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ const [savedId, setSavedId] = useState(0);
+ const [item, setItem] = useState({});
+
+ const limitCommands = useRestriction('limitCommands');
+
+ const handleSend = useCatch(async () => {
+ let command;
+ if (savedId) {
+ const response = await fetch(`/api/commands/${savedId}`);
+ if (response.ok) {
+ command = await response.json();
+ } else {
+ throw Error(await response.text());
+ }
+ } else {
+ command = item;
+ }
+
+ command.deviceId = parseInt(id, 10);
+
+ const response = await fetch('/api/commands/send', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(command),
+ });
+
+ if (response.ok) {
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const validate = () => savedId || (item && item.type);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'deviceCommand']}>
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ value={savedId}
+ emptyValue={limitCommands ? null : 0}
+ emptyTitle={t('sharedNew')}
+ onChange={(e) => setSavedId(e.target.value)}
+ endpoint={`/api/commands/send?deviceId=${id}`}
+ titleGetter={(it) => it.description}
+ label={t('sharedSavedCommand')}
+ />
+ {!limitCommands && !savedId && (
+ <BaseCommandView deviceId={id} item={item} setItem={setItem} />
+ )}
+ </AccordionDetails>
+ </Accordion>
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={handleSend}
+ disabled={!validate()}
+ >
+ {t('commandSend')}
+ </Button>
+ </div>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default CommandDevicePage;
diff --git a/src/settings/CommandGroupPage.jsx b/src/settings/CommandGroupPage.jsx
new file mode 100644
index 00000000..e55a235d
--- /dev/null
+++ b/src/settings/CommandGroupPage.jsx
@@ -0,0 +1,105 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+ Button,
+ FormControl,
+ InputLabel,
+ Select,
+ MenuItem,
+ FormControlLabel,
+ Checkbox,
+ TextField,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import { useCatch } from '../reactHelper';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const CommandDevicePage = () => {
+ const navigate = useNavigate();
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ const textEnabled = useSelector((state) => state.session.server.textEnabled);
+
+ const [item, setItem] = useState({ type: 'custom', attributes: {} });
+
+ const handleSend = useCatch(async () => {
+ const query = new URLSearchParams({ groupId: id });
+ const response = await fetch(`/api/commands/send?${query.toString()}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(item),
+ });
+
+ if (response.ok) {
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'deviceCommand']}>
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <FormControl fullWidth>
+ <InputLabel>{t('sharedType')}</InputLabel>
+ <Select label={t('sharedType')} value="custom" disabled>
+ <MenuItem value="custom">{t('commandCustom')}</MenuItem>
+ </Select>
+ </FormControl>
+ <TextField
+ value={item.attributes.data}
+ onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, data: e.target.value } })}
+ label={t('commandData')}
+ />
+ {textEnabled && (
+ <FormControlLabel
+ control={<Checkbox checked={item.textChannel} onChange={(event) => setItem({ ...item, textChannel: event.target.checked })} />}
+ label={t('commandSendSms')}
+ />
+ )}
+ </AccordionDetails>
+ </Accordion>
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={handleSend}
+ disabled={!item.attributes.data}
+ >
+ {t('commandSend')}
+ </Button>
+ </div>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default CommandDevicePage;
diff --git a/src/settings/CommandPage.jsx b/src/settings/CommandPage.jsx
new file mode 100644
index 00000000..e65ecd76
--- /dev/null
+++ b/src/settings/CommandPage.jsx
@@ -0,0 +1,50 @@
+import React, { useState } from 'react';
+import {
+ Accordion, AccordionSummary, AccordionDetails, Typography, TextField,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import EditItemView from './components/EditItemView';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import BaseCommandView from './components/BaseCommandView';
+import SettingsMenu from './components/SettingsMenu';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const CommandPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [item, setItem] = useState();
+
+ const validate = () => item && item.type;
+
+ return (
+ <EditItemView
+ endpoint="commands"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedSavedCommand']}
+ >
+ {item && (
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.description || ''}
+ onChange={(event) => setItem({ ...item, description: event.target.value })}
+ label={t('sharedDescription')}
+ />
+ <BaseCommandView item={item} setItem={setItem} />
+ </AccordionDetails>
+ </Accordion>
+ )}
+ </EditItemView>
+ );
+};
+
+export default CommandPage;
diff --git a/src/settings/CommandsPage.jsx b/src/settings/CommandsPage.jsx
new file mode 100644
index 00000000..1b893831
--- /dev/null
+++ b/src/settings/CommandsPage.jsx
@@ -0,0 +1,74 @@
+import React, { useState } from 'react';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import { formatBoolean } from '../common/util/formatter';
+import { prefixString } from '../common/util/stringUtils';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import { useRestriction } from '../common/util/permissions';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const CommandsPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const limitCommands = useRestriction('limitCommands');
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/commands');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedSavedCommands']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedDescription')}</TableCell>
+ <TableCell>{t('sharedType')}</TableCell>
+ <TableCell>{t('commandSendSms')}</TableCell>
+ {!limitCommands && <TableCell className={classes.columnAction} />}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.description}</TableCell>
+ <TableCell>{t(prefixString('command', item.type))}</TableCell>
+ <TableCell>{formatBoolean(item.textChannel, t)}</TableCell>
+ {!limitCommands && (
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions itemId={item.id} editPath="/settings/command" endpoint="commands" setTimestamp={setTimestamp} />
+ </TableCell>
+ )}
+ </TableRow>
+ )) : (<TableShimmer columns={limitCommands ? 3 : 4} endAction />)}
+ </TableBody>
+ </Table>
+ <CollectionFab editPath="/settings/command" disabled={limitCommands} />
+ </PageLayout>
+ );
+};
+
+export default CommandsPage;
diff --git a/src/settings/ComputedAttributePage.jsx b/src/settings/ComputedAttributePage.jsx
new file mode 100644
index 00000000..1b19033c
--- /dev/null
+++ b/src/settings/ComputedAttributePage.jsx
@@ -0,0 +1,177 @@
+import React, { useState } from 'react';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+ TextField,
+ createFilterOptions,
+ Autocomplete,
+ Button,
+ Snackbar,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import EditItemView from './components/EditItemView';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+import SettingsMenu from './components/SettingsMenu';
+import SelectField from '../common/components/SelectField';
+import { useCatch } from '../reactHelper';
+import { snackBarDurationLongMs } from '../common/util/duration';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const allowedProperties = ['valid', 'latitude', 'longitude', 'altitude', 'speed', 'course', 'address', 'accuracy'];
+
+const ComputedAttributePage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const [item, setItem] = useState();
+ const [deviceId, setDeviceId] = useState();
+ const [result, setResult] = useState();
+
+ const options = Object.entries(positionAttributes).filter(([key, value]) => !value.property || allowedProperties.includes(key)).map(([key, value]) => ({
+ key,
+ name: value.name,
+ type: value.type,
+ }));
+
+ const filter = createFilterOptions({
+ stringify: (option) => option.name,
+ });
+
+ const testAttribute = useCatch(async () => {
+ const query = new URLSearchParams({ deviceId });
+ const url = `/api/attributes/computed/test?${query.toString()}`;
+ const response = await fetch(url, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(item),
+ });
+ if (response.ok) {
+ setResult(await response.text());
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const validate = () => item && item.description && item.expression;
+
+ return (
+ <EditItemView
+ endpoint="attributes/computed"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedComputedAttribute']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.description || ''}
+ onChange={(e) => setItem({ ...item, description: e.target.value })}
+ label={t('sharedDescription')}
+ />
+ <Autocomplete
+ value={options.find((option) => option.key === item.attribute) || item.attribute}
+ onChange={(_, option) => {
+ const attribute = option ? option.key || option : null;
+ if (option && option.type) {
+ setItem({ ...item, attribute, type: option.type });
+ } else {
+ setItem({ ...item, attribute });
+ }
+ }}
+ filterOptions={(options, params) => {
+ const filtered = filter(options, params);
+ if (params.inputValue) {
+ filtered.push({
+ key: params.inputValue,
+ name: params.inputValue,
+ });
+ }
+ return filtered;
+ }}
+ options={options}
+ getOptionLabel={(option) => option.name || option}
+ renderOption={(props, option) => (
+ <li {...props}>
+ {option.name}
+ </li>
+ )}
+ renderInput={(params) => (
+ <TextField {...params} label={t('sharedAttribute')} />
+ )}
+ freeSolo
+ />
+ <TextField
+ value={item.expression || ''}
+ onChange={(e) => setItem({ ...item, expression: e.target.value })}
+ label={t('sharedExpression')}
+ multiline
+ rows={4}
+ />
+ <FormControl disabled={item.attribute in positionAttributes}>
+ <InputLabel>{t('sharedType')}</InputLabel>
+ <Select
+ label={t('sharedType')}
+ value={item.type || ''}
+ onChange={(e) => setItem({ ...item, type: e.target.value })}
+ >
+ <MenuItem value="string">{t('sharedTypeString')}</MenuItem>
+ <MenuItem value="number">{t('sharedTypeNumber')}</MenuItem>
+ <MenuItem value="boolean">{t('sharedTypeBoolean')}</MenuItem>
+ </Select>
+ </FormControl>
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedTest')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ value={deviceId}
+ onChange={(e) => setDeviceId(Number(e.target.value))}
+ endpoint="/api/devices"
+ label={t('sharedDevice')}
+ />
+ <Button
+ variant="outlined"
+ color="primary"
+ onClick={testAttribute}
+ disabled={!deviceId}
+ >
+ {t('sharedTestExpression')}
+ </Button>
+ <Snackbar
+ open={!!result}
+ onClose={() => setResult(null)}
+ autoHideDuration={snackBarDurationLongMs}
+ message={result}
+ />
+ </AccordionDetails>
+ </Accordion>
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default ComputedAttributePage;
diff --git a/src/settings/ComputedAttributesPage.jsx b/src/settings/ComputedAttributesPage.jsx
new file mode 100644
index 00000000..6d098547
--- /dev/null
+++ b/src/settings/ComputedAttributesPage.jsx
@@ -0,0 +1,74 @@
+import React, { useState } from 'react';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import { useAdministrator } from '../common/util/permissions';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const ComputedAttributesPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const administrator = useAdministrator();
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/attributes/computed');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedComputedAttributes']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedDescription')}</TableCell>
+ <TableCell>{t('sharedAttribute')}</TableCell>
+ <TableCell>{t('sharedExpression')}</TableCell>
+ <TableCell>{t('sharedType')}</TableCell>
+ {administrator && <TableCell className={classes.columnAction} />}
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.description}</TableCell>
+ <TableCell>{item.attribute}</TableCell>
+ <TableCell>{item.expression}</TableCell>
+ <TableCell>{item.type}</TableCell>
+ {administrator && (
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions itemId={item.id} editPath="/settings/attribute" endpoint="attributes/computed" setTimestamp={setTimestamp} />
+ </TableCell>
+ )}
+ </TableRow>
+ )) : (<TableShimmer columns={administrator ? 5 : 4} endAction={administrator} />)}
+ </TableBody>
+ </Table>
+ <CollectionFab editPath="/settings/attribute" disabled={!administrator} />
+ </PageLayout>
+ );
+};
+
+export default ComputedAttributesPage;
diff --git a/src/settings/DeviceConnectionsPage.jsx b/src/settings/DeviceConnectionsPage.jsx
new file mode 100644
index 00000000..c711d719
--- /dev/null
+++ b/src/settings/DeviceConnectionsPage.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { useParams } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import LinkField from '../common/components/LinkField';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import SettingsMenu from './components/SettingsMenu';
+import { formatNotificationTitle } from '../common/util/formatter';
+import PageLayout from '../common/components/PageLayout';
+import useFeatures from '../common/util/useFeatures';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const DeviceConnectionsPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ const features = useFeatures();
+
+ return (
+ <PageLayout
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedDevice', 'sharedConnections']}
+ >
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedConnections')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <LinkField
+ endpointAll="/api/geofences"
+ endpointLinked={`/api/geofences?deviceId=${id}`}
+ baseId={id}
+ keyBase="deviceId"
+ keyLink="geofenceId"
+ label={t('sharedGeofences')}
+ />
+ <LinkField
+ endpointAll="/api/notifications"
+ endpointLinked={`/api/notifications?deviceId=${id}`}
+ baseId={id}
+ keyBase="deviceId"
+ keyLink="notificationId"
+ titleGetter={(it) => formatNotificationTitle(t, it)}
+ label={t('sharedNotifications')}
+ />
+ {!features.disableDrivers && (
+ <LinkField
+ endpointAll="/api/drivers"
+ endpointLinked={`/api/drivers?deviceId=${id}`}
+ baseId={id}
+ keyBase="deviceId"
+ keyLink="driverId"
+ titleGetter={(it) => `${it.name} (${it.uniqueId})`}
+ label={t('sharedDrivers')}
+ />
+ )}
+ {!features.disableComputedAttributes && (
+ <LinkField
+ endpointAll="/api/attributes/computed"
+ endpointLinked={`/api/attributes/computed?deviceId=${id}`}
+ baseId={id}
+ keyBase="deviceId"
+ keyLink="attributeId"
+ titleGetter={(it) => it.description}
+ label={t('sharedComputedAttributes')}
+ />
+ )}
+ {!features.disableSavedCommands && (
+ <LinkField
+ endpointAll="/api/commands"
+ endpointLinked={`/api/commands?deviceId=${id}`}
+ baseId={id}
+ keyBase="deviceId"
+ keyLink="commandId"
+ titleGetter={(it) => it.description}
+ label={t('sharedSavedCommands')}
+ />
+ )}
+ {!features.disableMaintenance && (
+ <LinkField
+ endpointAll="/api/maintenance"
+ endpointLinked={`/api/maintenance?deviceId=${id}`}
+ baseId={id}
+ keyBase="deviceId"
+ keyLink="maintenanceId"
+ label={t('sharedMaintenance')}
+ />
+ )}
+ </AccordionDetails>
+ </Accordion>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default DeviceConnectionsPage;
diff --git a/src/settings/DevicePage.jsx b/src/settings/DevicePage.jsx
new file mode 100644
index 00000000..8933a210
--- /dev/null
+++ b/src/settings/DevicePage.jsx
@@ -0,0 +1,176 @@
+import React, { useState } from 'react';
+import dayjs from 'dayjs';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ FormControlLabel,
+ Checkbox,
+ TextField,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { DropzoneArea } from 'react-mui-dropzone';
+import EditItemView from './components/EditItemView';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import SelectField from '../common/components/SelectField';
+import deviceCategories from '../common/util/deviceCategories';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import useDeviceAttributes from '../common/attributes/useDeviceAttributes';
+import { useAdministrator } from '../common/util/permissions';
+import SettingsMenu from './components/SettingsMenu';
+import useCommonDeviceAttributes from '../common/attributes/useCommonDeviceAttributes';
+import { useCatch } from '../reactHelper';
+import useQuery from '../common/util/useQuery';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const DevicePage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const admin = useAdministrator();
+
+ const commonDeviceAttributes = useCommonDeviceAttributes(t);
+ const deviceAttributes = useDeviceAttributes(t);
+
+ const query = useQuery();
+ const uniqueId = query.get('uniqueId');
+
+ const [item, setItem] = useState(uniqueId ? { uniqueId } : null);
+
+ const handleFiles = useCatch(async (files) => {
+ if (files.length > 0) {
+ const response = await fetch(`/api/devices/${item.id}/image`, {
+ method: 'POST',
+ body: files[0],
+ });
+ if (response.ok) {
+ setItem({ ...item, attributes: { ...item.attributes, deviceImage: await response.text() } });
+ } else {
+ throw Error(await response.text());
+ }
+ }
+ });
+
+ const validate = () => item && item.name && item.uniqueId;
+
+ return (
+ <EditItemView
+ endpoint="devices"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedDevice']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.name || ''}
+ onChange={(event) => setItem({ ...item, name: event.target.value })}
+ label={t('sharedName')}
+ />
+ <TextField
+ value={item.uniqueId || ''}
+ onChange={(event) => setItem({ ...item, uniqueId: event.target.value })}
+ label={t('deviceIdentifier')}
+ helperText={t('deviceIdentifierHelp')}
+ disabled={Boolean(uniqueId)}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedExtra')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ value={item.groupId}
+ onChange={(event) => setItem({ ...item, groupId: Number(event.target.value) })}
+ endpoint="/api/groups"
+ label={t('groupParent')}
+ />
+ <TextField
+ value={item.phone || ''}
+ onChange={(event) => setItem({ ...item, phone: event.target.value })}
+ label={t('sharedPhone')}
+ />
+ <TextField
+ value={item.model || ''}
+ onChange={(event) => setItem({ ...item, model: event.target.value })}
+ label={t('deviceModel')}
+ />
+ <TextField
+ value={item.contact || ''}
+ onChange={(event) => setItem({ ...item, contact: event.target.value })}
+ label={t('deviceContact')}
+ />
+ <SelectField
+ value={item.category || 'default'}
+ onChange={(event) => setItem({ ...item, category: event.target.value })}
+ data={deviceCategories.map((category) => ({
+ id: category,
+ name: t(`category${category.replace(/^\w/, (c) => c.toUpperCase())}`),
+ }))}
+ label={t('deviceCategory')}
+ />
+ <SelectField
+ value={item.calendarId}
+ onChange={(event) => setItem({ ...item, calendarId: Number(event.target.value) })}
+ endpoint="/api/calendars"
+ label={t('sharedCalendar')}
+ />
+ <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={!admin}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.disabled} onChange={(event) => setItem({ ...item, disabled: event.target.checked })} />}
+ label={t('sharedDisabled')}
+ disabled={!admin}
+ />
+ </AccordionDetails>
+ </Accordion>
+ {item.id && (
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('attributeDeviceImage')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <DropzoneArea
+ dropzoneText={t('sharedDropzoneText')}
+ acceptedFiles={['image/*']}
+ filesLimit={1}
+ onChange={handleFiles}
+ showAlerts={false}
+ maxFileSize={500000}
+ />
+ </AccordionDetails>
+ </Accordion>
+ )}
+ <EditAttributesAccordion
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={{ ...commonDeviceAttributes, ...deviceAttributes }}
+ />
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default DevicePage;
diff --git a/src/settings/DevicesPage.jsx b/src/settings/DevicesPage.jsx
new file mode 100644
index 00000000..c0da0ba7
--- /dev/null
+++ b/src/settings/DevicesPage.jsx
@@ -0,0 +1,114 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody, Button, TableFooter,
+} from '@mui/material';
+import LinkIcon from '@mui/icons-material/Link';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import { usePreference } from '../common/util/preferences';
+import { formatTime } from '../common/util/formatter';
+import { useDeviceReadonly } from '../common/util/permissions';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const DevicesPage = () => {
+ const classes = useSettingsStyles();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const groups = useSelector((state) => state.groups.items);
+
+ const hours12 = usePreference('twelveHourFormat');
+
+ const deviceReadonly = useDeviceReadonly();
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/devices');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ const handleExport = () => {
+ window.location.assign('/api/reports/devices/xlsx');
+ };
+
+ const actionConnections = {
+ key: 'connections',
+ title: t('sharedConnections'),
+ icon: <LinkIcon fontSize="small" />,
+ handler: (deviceId) => navigate(`/settings/device/${deviceId}/connections`),
+ };
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'deviceTitle']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell>{t('deviceIdentifier')}</TableCell>
+ <TableCell>{t('groupParent')}</TableCell>
+ <TableCell>{t('sharedPhone')}</TableCell>
+ <TableCell>{t('deviceModel')}</TableCell>
+ <TableCell>{t('deviceContact')}</TableCell>
+ <TableCell>{t('userExpirationTime')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.name}</TableCell>
+ <TableCell>{item.uniqueId}</TableCell>
+ <TableCell>{item.groupId ? groups[item.groupId]?.name : null}</TableCell>
+ <TableCell>{item.phone}</TableCell>
+ <TableCell>{item.model}</TableCell>
+ <TableCell>{item.contact}</TableCell>
+ <TableCell>{formatTime(item.expirationTime, 'date', hours12)}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions
+ itemId={item.id}
+ editPath="/settings/device"
+ endpoint="devices"
+ setTimestamp={setTimestamp}
+ customActions={[actionConnections]}
+ readonly={deviceReadonly}
+ />
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={7} endAction />)}
+ </TableBody>
+ <TableFooter>
+ <TableRow>
+ <TableCell colSpan={8} align="right">
+ <Button onClick={handleExport} variant="text">{t('reportExport')}</Button>
+ </TableCell>
+ </TableRow>
+ </TableFooter>
+ </Table>
+ <CollectionFab editPath="/settings/device" />
+ </PageLayout>
+ );
+};
+
+export default DevicesPage;
diff --git a/src/settings/DriverPage.jsx b/src/settings/DriverPage.jsx
new file mode 100644
index 00000000..5f70a44a
--- /dev/null
+++ b/src/settings/DriverPage.jsx
@@ -0,0 +1,62 @@
+import React, { useState } from 'react';
+import TextField from '@mui/material/TextField';
+import {
+ Accordion, AccordionSummary, AccordionDetails, Typography,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import EditItemView from './components/EditItemView';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import SettingsMenu from './components/SettingsMenu';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const DriverPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [item, setItem] = useState();
+
+ const validate = () => item && item.name && item.uniqueId;
+
+ return (
+ <EditItemView
+ endpoint="drivers"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedDriver']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.name || ''}
+ onChange={(event) => setItem({ ...item, name: event.target.value })}
+ label={t('sharedName')}
+ />
+ <TextField
+ value={item.uniqueId || ''}
+ onChange={(event) => setItem({ ...item, uniqueId: event.target.value })}
+ label={t('deviceIdentifier')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <EditAttributesAccordion
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={{}}
+ />
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default DriverPage;
diff --git a/src/settings/DriversPage.jsx b/src/settings/DriversPage.jsx
new file mode 100644
index 00000000..72834860
--- /dev/null
+++ b/src/settings/DriversPage.jsx
@@ -0,0 +1,66 @@
+import React, { useState } from 'react';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const DriversPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/drivers');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedDrivers']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell>{t('deviceIdentifier')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.name}</TableCell>
+ <TableCell>{item.uniqueId}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions itemId={item.id} editPath="/settings/driver" endpoint="drivers" setTimestamp={setTimestamp} />
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={3} endAction />)}
+ </TableBody>
+ </Table>
+ <CollectionFab editPath="/settings/driver" />
+ </PageLayout>
+ );
+};
+
+export default DriversPage;
diff --git a/src/settings/GeofencePage.jsx b/src/settings/GeofencePage.jsx
new file mode 100644
index 00000000..c3c96ef8
--- /dev/null
+++ b/src/settings/GeofencePage.jsx
@@ -0,0 +1,88 @@
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import {
+ Accordion, AccordionSummary, AccordionDetails, Typography, TextField,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import EditItemView from './components/EditItemView';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import useGeofenceAttributes from '../common/attributes/useGeofenceAttributes';
+import SettingsMenu from './components/SettingsMenu';
+import SelectField from '../common/components/SelectField';
+import { geofencesActions } from '../store';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const GeofencePage = () => {
+ const classes = useSettingsStyles();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const geofenceAttributes = useGeofenceAttributes(t);
+
+ const [item, setItem] = useState();
+
+ const onItemSaved = (result) => {
+ dispatch(geofencesActions.update([result]));
+ };
+
+ const validate = () => item && item.name;
+
+ return (
+ <EditItemView
+ endpoint="geofences"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ onItemSaved={onItemSaved}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedGeofence']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.name || ''}
+ onChange={(event) => setItem({ ...item, name: event.target.value })}
+ label={t('sharedName')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedExtra')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.description || ''}
+ onChange={(event) => setItem({ ...item, description: event.target.value })}
+ label={t('sharedDescription')}
+ />
+ <SelectField
+ value={item.calendarId}
+ onChange={(event) => setItem({ ...item, calendarId: Number(event.target.value) })}
+ endpoint="/api/calendars"
+ label={t('sharedCalendar')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <EditAttributesAccordion
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={geofenceAttributes}
+ />
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default GeofencePage;
diff --git a/src/settings/GroupConnectionsPage.jsx b/src/settings/GroupConnectionsPage.jsx
new file mode 100644
index 00000000..980bd9da
--- /dev/null
+++ b/src/settings/GroupConnectionsPage.jsx
@@ -0,0 +1,107 @@
+import React from 'react';
+import { useParams } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import LinkField from '../common/components/LinkField';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import SettingsMenu from './components/SettingsMenu';
+import { formatNotificationTitle } from '../common/util/formatter';
+import PageLayout from '../common/components/PageLayout';
+import useFeatures from '../common/util/useFeatures';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const GroupConnectionsPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ const features = useFeatures();
+
+ return (
+ <PageLayout
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'groupDialog', 'sharedConnections']}
+ >
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedConnections')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <LinkField
+ endpointAll="/api/geofences"
+ endpointLinked={`/api/geofences?groupId=${id}`}
+ baseId={id}
+ keyBase="groupId"
+ keyLink="geofenceId"
+ label={t('sharedGeofences')}
+ />
+ <LinkField
+ endpointAll="/api/notifications"
+ endpointLinked={`/api/notifications?groupId=${id}`}
+ baseId={id}
+ keyBase="groupId"
+ keyLink="notificationId"
+ titleGetter={(it) => formatNotificationTitle(t, it)}
+ label={t('sharedNotifications')}
+ />
+ {!features.disableDrivers && (
+ <LinkField
+ endpointAll="/api/drivers"
+ endpointLinked={`/api/drivers?groupId=${id}`}
+ baseId={id}
+ keyBase="groupId"
+ keyLink="driverId"
+ titleGetter={(it) => `${it.name} (${it.uniqueId})`}
+ label={t('sharedDrivers')}
+ />
+ )}
+ {!features.disableComputedAttributes && (
+ <LinkField
+ endpointAll="/api/attributes/computed"
+ endpointLinked={`/api/attributes/computed?groupId=${id}`}
+ baseId={id}
+ keyBase="groupId"
+ keyLink="attributeId"
+ titleGetter={(it) => it.description}
+ label={t('sharedComputedAttributes')}
+ />
+ )}
+ {!features.disableSavedCommands && (
+ <LinkField
+ endpointAll="/api/commands"
+ endpointLinked={`/api/commands?groupId=${id}`}
+ baseId={id}
+ keyBase="groupId"
+ keyLink="commandId"
+ titleGetter={(it) => it.description}
+ label={t('sharedSavedCommands')}
+ />
+ )}
+ {!features.disableMaintenance && (
+ <LinkField
+ endpointAll="/api/maintenance"
+ endpointLinked={`/api/maintenance?groupId=${id}`}
+ baseId={id}
+ keyBase="groupId"
+ keyLink="maintenanceId"
+ label={t('sharedMaintenance')}
+ />
+ )}
+ </AccordionDetails>
+ </Accordion>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default GroupConnectionsPage;
diff --git a/src/settings/GroupPage.jsx b/src/settings/GroupPage.jsx
new file mode 100644
index 00000000..ba1cbc76
--- /dev/null
+++ b/src/settings/GroupPage.jsx
@@ -0,0 +1,93 @@
+import React, { useState } from 'react';
+import { useDispatch } from 'react-redux';
+import TextField from '@mui/material/TextField';
+
+import {
+ Accordion, AccordionSummary, AccordionDetails, Typography,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import EditItemView from './components/EditItemView';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import SelectField from '../common/components/SelectField';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import SettingsMenu from './components/SettingsMenu';
+import useCommonDeviceAttributes from '../common/attributes/useCommonDeviceAttributes';
+import useGroupAttributes from '../common/attributes/useGroupAttributes';
+import { useCatch } from '../reactHelper';
+import { groupsActions } from '../store';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const GroupPage = () => {
+ const classes = useSettingsStyles();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const commonDeviceAttributes = useCommonDeviceAttributes(t);
+ const groupAttributes = useGroupAttributes(t);
+
+ const [item, setItem] = useState();
+
+ const onItemSaved = useCatch(async () => {
+ const response = await fetch('/api/groups');
+ if (response.ok) {
+ dispatch(groupsActions.refresh(await response.json()));
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const validate = () => item && item.name;
+
+ return (
+ <EditItemView
+ endpoint="groups"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ onItemSaved={onItemSaved}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'groupDialog']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.name || ''}
+ onChange={(event) => setItem({ ...item, name: event.target.value })}
+ label={t('sharedName')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedExtra')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ value={item.groupId}
+ onChange={(event) => setItem({ ...item, groupId: Number(event.target.value) })}
+ endpoint="/api/groups"
+ label={t('groupParent')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <EditAttributesAccordion
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={{ ...commonDeviceAttributes, ...groupAttributes }}
+ />
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default GroupPage;
diff --git a/src/settings/GroupsPage.jsx b/src/settings/GroupsPage.jsx
new file mode 100644
index 00000000..baba7f72
--- /dev/null
+++ b/src/settings/GroupsPage.jsx
@@ -0,0 +1,91 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import LinkIcon from '@mui/icons-material/Link';
+import PublishIcon from '@mui/icons-material/Publish';
+import { useEffectAsync } from '../reactHelper';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import { useRestriction } from '../common/util/permissions';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const GroupsPage = () => {
+ const classes = useSettingsStyles();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const limitCommands = useRestriction('limitCommands');
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/groups');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ const actionCommand = {
+ key: 'command',
+ title: t('deviceCommand'),
+ icon: <PublishIcon fontSize="small" />,
+ handler: (groupId) => navigate(`/settings/group/${groupId}/command`),
+ };
+
+ const actionConnections = {
+ key: 'connections',
+ title: t('sharedConnections'),
+ icon: <LinkIcon fontSize="small" />,
+ handler: (groupId) => navigate(`/settings/group/${groupId}/connections`),
+ };
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'settingsGroups']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.name}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions
+ itemId={item.id}
+ editPath="/settings/group"
+ endpoint="groups"
+ setTimestamp={setTimestamp}
+ customActions={limitCommands ? [actionConnections] : [actionConnections, actionCommand]}
+ />
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={2} endAction />)}
+ </TableBody>
+ </Table>
+ <CollectionFab editPath="/settings/group" />
+ </PageLayout>
+ );
+};
+
+export default GroupsPage;
diff --git a/src/settings/MaintenancePage.jsx b/src/settings/MaintenancePage.jsx
new file mode 100644
index 00000000..491a0d3b
--- /dev/null
+++ b/src/settings/MaintenancePage.jsx
@@ -0,0 +1,174 @@
+import React, { useEffect, useState } from 'react';
+import dayjs from 'dayjs';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ TextField,
+ FormControl,
+ InputLabel,
+ MenuItem,
+ Select,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { prefixString } from '../common/util/stringUtils';
+import EditItemView from './components/EditItemView';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import { useAttributePreference } from '../common/util/preferences';
+import {
+ speedFromKnots, speedToKnots, distanceFromMeters, distanceToMeters,
+} from '../common/util/converter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+import SettingsMenu from './components/SettingsMenu';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const MaintenancePage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const [item, setItem] = useState();
+ const [labels, setLabels] = useState({ start: '', period: '' });
+
+ const speedUnit = useAttributePreference('speedUnit', 'kn');
+ const distanceUnit = useAttributePreference('distanceUnit', 'km');
+
+ const convertToList = (attributes) => {
+ const otherList = [];
+ Object.keys(attributes).forEach((key) => {
+ const value = attributes[key];
+ if (value.type === 'number' || key.endsWith('Time')) {
+ otherList.push({ key, name: value.name, type: value.type });
+ }
+ });
+ return otherList;
+ };
+
+ useEffect(() => {
+ const attribute = positionAttributes[item?.type];
+ if (item?.type?.endsWith('Time')) {
+ setLabels({ ...labels, start: null, period: t('sharedDays') });
+ } else if (attribute && attribute.dataType) {
+ switch (attribute.dataType) {
+ case 'distance':
+ setLabels({ ...labels, start: t(prefixString('shared', distanceUnit)), period: t(prefixString('shared', distanceUnit)) });
+ break;
+ case 'speed':
+ setLabels({ ...labels, start: t(prefixString('shared', speedUnit)), period: t(prefixString('shared', speedUnit)) });
+ break;
+ default:
+ setLabels({ ...labels, start: null, period: null });
+ break;
+ }
+ } else {
+ setLabels({ ...labels, start: null, period: null });
+ }
+ }, [item?.type]);
+
+ const rawToValue = (start, value) => {
+ const attribute = positionAttributes[item.type];
+ if (item.type?.endsWith('Time')) {
+ if (start) {
+ return dayjs(value).locale('en').format('YYYY-MM-DD');
+ }
+ return value / 86400000;
+ }
+ if (attribute && attribute.dataType) {
+ switch (attribute.dataType) {
+ case 'speed':
+ return speedFromKnots(value, speedUnit);
+ case 'distance':
+ return distanceFromMeters(value, distanceUnit);
+ default:
+ return value;
+ }
+ }
+ return value;
+ };
+
+ const valueToRaw = (start, value) => {
+ const attribute = positionAttributes[item.type];
+ if (item.type?.endsWith('Time')) {
+ if (start) {
+ return dayjs(value, 'YYYY-MM-DD').valueOf();
+ }
+ return value * 86400000;
+ } if (attribute && attribute.dataType) {
+ switch (attribute.dataType) {
+ case 'speed':
+ return speedToKnots(value, speedUnit);
+ case 'distance':
+ return distanceToMeters(value, distanceUnit);
+ default:
+ return value;
+ }
+ }
+ return value;
+ };
+
+ const validate = () => item && item.name && item.type && item.start && item.period;
+
+ return (
+ <EditItemView
+ endpoint="maintenance"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedMaintenance']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <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')}
+ />
+ <FormControl>
+ <InputLabel>{t('sharedType')}</InputLabel>
+ <Select
+ label={t('sharedType')}
+ value={item.type || ''}
+ onChange={(e) => setItem({ ...item, type: e.target.value, start: 0, period: 0 })}
+ >
+ {convertToList(positionAttributes).map(({ key, name }) => (
+ <MenuItem key={key} value={key}>{name}</MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <TextField
+ type={item.type?.endsWith('Time') ? 'date' : 'number'}
+ value={rawToValue(true, item.start) || ''}
+ onChange={(e) => setItem({ ...item, start: valueToRaw(true, e.target.value) })}
+ label={labels.start ? `${t('maintenanceStart')} (${labels.start})` : t('maintenanceStart')}
+ />
+ <TextField
+ type="number"
+ value={rawToValue(false, item.period) || ''}
+ onChange={(e) => setItem({ ...item, period: valueToRaw(false, e.target.value) })}
+ label={labels.period ? `${t('maintenancePeriod')} (${labels.period})` : t('maintenancePeriod')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <EditAttributesAccordion
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={{}}
+ />
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default MaintenancePage;
diff --git a/src/settings/MaintenancesPage.jsx b/src/settings/MaintenancesPage.jsx
new file mode 100644
index 00000000..9241eb3e
--- /dev/null
+++ b/src/settings/MaintenancesPage.jsx
@@ -0,0 +1,100 @@
+import React, { useState } from 'react';
+import dayjs from 'dayjs';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import { useEffectAsync } from '../reactHelper';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+import { formatDistance, formatSpeed } from '../common/util/formatter';
+import { useAttributePreference } from '../common/util/preferences';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const MaintenacesPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const speedUnit = useAttributePreference('speedUnit');
+ const distanceUnit = useAttributePreference('distanceUnit');
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/maintenance');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ const convertAttribute = (key, start, value) => {
+ const attribute = positionAttributes[key];
+ if (key.endsWith('Time')) {
+ if (start) {
+ return dayjs(value).locale('en').format('YYYY-MM-DD');
+ }
+ return `${value / 86400000} ${t('sharedDays')}`;
+ }
+ if (attribute && attribute.dataType) {
+ switch (attribute.dataType) {
+ case 'speed':
+ return formatSpeed(value, speedUnit, t);
+ case 'distance':
+ return formatDistance(value, distanceUnit, t);
+ default:
+ return value;
+ }
+ }
+
+ return value;
+ };
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedMaintenance']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell>{t('sharedType')}</TableCell>
+ <TableCell>{t('maintenanceStart')}</TableCell>
+ <TableCell>{t('maintenancePeriod')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.name}</TableCell>
+ <TableCell>{item.type}</TableCell>
+ <TableCell>{convertAttribute(item.type, true, item.start)}</TableCell>
+ <TableCell>{convertAttribute(item.type, false, item.period)}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions itemId={item.id} editPath="/settings/maintenance" endpoint="maintenance" setTimestamp={setTimestamp} />
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={5} endAction />)}
+ </TableBody>
+ </Table>
+ <CollectionFab editPath="/settings/maintenance" />
+ </PageLayout>
+ );
+};
+
+export default MaintenacesPage;
diff --git a/src/settings/NotificationPage.jsx b/src/settings/NotificationPage.jsx
new file mode 100644
index 00000000..63aa9b95
--- /dev/null
+++ b/src/settings/NotificationPage.jsx
@@ -0,0 +1,144 @@
+import React, { useState } from 'react';
+
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ FormControlLabel,
+ Checkbox,
+ FormGroup,
+ Button,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { useTranslation, useTranslationKeys } from '../common/components/LocalizationProvider';
+import EditItemView from './components/EditItemView';
+import { prefixString, unprefixString } from '../common/util/stringUtils';
+import SelectField from '../common/components/SelectField';
+import SettingsMenu from './components/SettingsMenu';
+import { useCatch } from '../reactHelper';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const NotificationPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [item, setItem] = useState();
+
+ const alarms = useTranslationKeys((it) => it.startsWith('alarm')).map((it) => ({
+ key: unprefixString('alarm', it),
+ name: t(it),
+ }));
+
+ const testNotificators = useCatch(async () => {
+ await Promise.all(item.notificators.split(/[, ]+/).map(async (notificator) => {
+ const response = await fetch(`/api/notifications/test/${notificator}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(item),
+ });
+ if (!response.ok) {
+ throw Error(await response.text());
+ }
+ }));
+ });
+
+ const validate = () => item && item.type && item.notificators && (!item.notificators?.includes('command') || item.commandId);
+
+ return (
+ <EditItemView
+ endpoint="notifications"
+ item={item}
+ setItem={setItem}
+ validate={validate}
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'sharedNotification']}
+ >
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ value={item.type}
+ onChange={(e) => setItem({ ...item, type: e.target.value })}
+ endpoint="/api/notifications/types"
+ keyGetter={(it) => it.type}
+ titleGetter={(it) => t(prefixString('event', it.type))}
+ label={t('sharedType')}
+ />
+ {item.type === 'alarm' && (
+ <SelectField
+ multiple
+ value={item.attributes && item.attributes.alarms ? item.attributes.alarms.split(/[, ]+/) : []}
+ onChange={(e) => setItem({ ...item, attributes: { ...item.attributes, alarms: e.target.value.join() } })}
+ data={alarms}
+ keyGetter={(it) => it.key}
+ label={t('sharedAlarms')}
+ />
+ )}
+ <SelectField
+ multiple
+ value={item.notificators ? item.notificators.split(/[, ]+/) : []}
+ onChange={(e) => setItem({ ...item, notificators: e.target.value.join() })}
+ endpoint="/api/notifications/notificators"
+ keyGetter={(it) => it.type}
+ titleGetter={(it) => t(prefixString('notificator', it.type))}
+ label={t('notificationNotificators')}
+ />
+ {item.notificators?.includes('command') && (
+ <SelectField
+ value={item.commandId}
+ onChange={(event) => setItem({ ...item, commandId: Number(event.target.value) })}
+ endpoint="/api/commands"
+ titleGetter={(it) => it.description}
+ label={t('sharedSavedCommand')}
+ />
+ )}
+ <Button
+ variant="outlined"
+ color="primary"
+ onClick={testNotificators}
+ disabled={!item.notificators}
+ >
+ {t('sharedTestNotificators')}
+ </Button>
+ <FormGroup>
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={item.always}
+ onChange={(event) => setItem({ ...item, always: event.target.checked })}
+ />
+ )}
+ label={t('notificationAlways')}
+ />
+ </FormGroup>
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedExtra')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ value={item.calendarId}
+ onChange={(event) => setItem({ ...item, calendarId: Number(event.target.value) })}
+ endpoint="/api/calendars"
+ label={t('sharedCalendar')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ </>
+ )}
+ </EditItemView>
+ );
+};
+
+export default NotificationPage;
diff --git a/src/settings/NotificationsPage.jsx b/src/settings/NotificationsPage.jsx
new file mode 100644
index 00000000..f1e70a85
--- /dev/null
+++ b/src/settings/NotificationsPage.jsx
@@ -0,0 +1,83 @@
+import React, { useState } from 'react';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody,
+} from '@mui/material';
+import { useEffectAsync } from '../reactHelper';
+import { prefixString } from '../common/util/stringUtils';
+import { formatBoolean } from '../common/util/formatter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const NotificationsPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/notifications');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ const formatList = (prefix, value) => {
+ if (value) {
+ return value
+ .split(/[, ]+/)
+ .filter(Boolean)
+ .map((it) => t(prefixString(prefix, it)))
+ .join(', ');
+ }
+ return '';
+ };
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedNotifications']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('notificationType')}</TableCell>
+ <TableCell>{t('notificationAlways')}</TableCell>
+ <TableCell>{t('sharedAlarms')}</TableCell>
+ <TableCell>{t('notificationNotificators')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{t(prefixString('event', item.type))}</TableCell>
+ <TableCell>{formatBoolean(item.always, t)}</TableCell>
+ <TableCell>{formatList('alarm', item.attributes.alarms)}</TableCell>
+ <TableCell>{formatList('notificator', item.notificators)}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions itemId={item.id} editPath="/settings/notification" endpoint="notifications" setTimestamp={setTimestamp} />
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={5} endAction />)}
+ </TableBody>
+ </Table>
+ <CollectionFab editPath="/settings/notification" />
+ </PageLayout>
+ );
+};
+
+export default NotificationsPage;
diff --git a/src/settings/PreferencesPage.jsx b/src/settings/PreferencesPage.jsx
new file mode 100644
index 00000000..2d6df62f
--- /dev/null
+++ b/src/settings/PreferencesPage.jsx
@@ -0,0 +1,375 @@
+import React, { useState } from 'react';
+import dayjs from 'dayjs';
+import { useDispatch, useSelector } from 'react-redux';
+import { useNavigate } from 'react-router-dom';
+import {
+ Accordion, AccordionSummary, AccordionDetails, Typography, Container, FormControl, InputLabel, Select, MenuItem, Checkbox, FormControlLabel, FormGroup, InputAdornment, IconButton, OutlinedInput, Autocomplete, TextField, createFilterOptions, Button,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import CachedIcon from '@mui/icons-material/Cached';
+import ContentCopyIcon from '@mui/icons-material/ContentCopy';
+import { useTranslation, useTranslationKeys } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import usePositionAttributes from '../common/attributes/usePositionAttributes';
+import { prefixString, unprefixString } from '../common/util/stringUtils';
+import SelectField from '../common/components/SelectField';
+import useMapStyles from '../map/core/useMapStyles';
+import useMapOverlays from '../map/overlay/useMapOverlays';
+import { useCatch } from '../reactHelper';
+import { sessionActions } from '../store';
+import { useRestriction } from '../common/util/permissions';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const deviceFields = [
+ { id: 'name', name: 'sharedName' },
+ { id: 'uniqueId', name: 'deviceIdentifier' },
+ { id: 'phone', name: 'sharedPhone' },
+ { id: 'model', name: 'deviceModel' },
+ { id: 'contact', name: 'deviceContact' },
+];
+
+const PreferencesPage = () => {
+ const classes = useSettingsStyles();
+ const dispatch = useDispatch();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const readonly = useRestriction('readonly');
+
+ const user = useSelector((state) => state.session.user);
+ const [attributes, setAttributes] = useState(user.attributes);
+
+ const versionApp = import.meta.env.VITE_APP_VERSION.slice(0, -2);
+ const versionServer = useSelector((state) => state.session.server.version);
+ const socket = useSelector((state) => state.session.socket);
+
+ const [token, setToken] = useState(null);
+ const [tokenExpiration, setTokenExpiration] = useState(dayjs().add(1, 'week').locale('en').format('YYYY-MM-DD'));
+
+ const mapStyles = useMapStyles();
+ const mapOverlays = useMapOverlays();
+
+ const positionAttributes = usePositionAttributes(t);
+
+ const filter = createFilterOptions();
+
+ const generateToken = useCatch(async () => {
+ const expiration = dayjs(tokenExpiration, 'YYYY-MM-DD').toISOString();
+ const response = await fetch('/api/session/token', {
+ method: 'POST',
+ body: new URLSearchParams(`expiration=${expiration}`),
+ });
+ if (response.ok) {
+ setToken(await response.text());
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const alarms = useTranslationKeys((it) => it.startsWith('alarm')).map((it) => ({
+ key: unprefixString('alarm', it),
+ name: t(it),
+ }));
+
+ const handleSave = useCatch(async () => {
+ const response = await fetch(`/api/users/${user.id}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ ...user, attributes }),
+ });
+ if (response.ok) {
+ dispatch(sessionActions.updateUser(await response.json()));
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'sharedPreferences']}>
+ <Container maxWidth="xs" className={classes.container}>
+ {!readonly && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('mapTitle')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <FormControl>
+ <InputLabel>{t('mapActive')}</InputLabel>
+ <Select
+ label={t('mapActive')}
+ value={attributes.activeMapStyles?.split(',') || ['locationIqStreets', 'osm', 'carto']}
+ onChange={(e, child) => {
+ const clicked = mapStyles.find((s) => s.id === child.props.value);
+ if (clicked.available) {
+ setAttributes({ ...attributes, activeMapStyles: e.target.value.join(',') });
+ } else if (clicked.id !== 'custom') {
+ const query = new URLSearchParams({ attribute: clicked.attribute });
+ navigate(`/settings/user/${user.id}?${query.toString()}`);
+ }
+ }}
+ multiple
+ >
+ {mapStyles.map((style) => (
+ <MenuItem key={style.id} value={style.id}>
+ <Typography component="span" color={style.available ? 'textPrimary' : 'error'}>{style.title}</Typography>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('mapOverlay')}</InputLabel>
+ <Select
+ label={t('mapOverlay')}
+ value={attributes.selectedMapOverlay || ''}
+ onChange={(e) => {
+ const clicked = mapOverlays.find((o) => o.id === e.target.value);
+ if (!clicked || clicked.available) {
+ setAttributes({ ...attributes, selectedMapOverlay: e.target.value });
+ } else if (clicked.id !== 'custom') {
+ const query = new URLSearchParams({ attribute: clicked.attribute });
+ navigate(`/settings/user/${user.id}?${query.toString()}`);
+ }
+ }}
+ >
+ <MenuItem value="">{'\u00a0'}</MenuItem>
+ {mapOverlays.map((overlay) => (
+ <MenuItem key={overlay.id} value={overlay.id}>
+ <Typography component="span" color={overlay.available ? 'textPrimary' : 'error'}>{overlay.title}</Typography>
+ </MenuItem>
+ ))}
+ </Select>
+ </FormControl>
+ <Autocomplete
+ multiple
+ freeSolo
+ options={Object.keys(positionAttributes)}
+ getOptionLabel={(option) => (positionAttributes[option]?.name || option)}
+ value={attributes.positionItems?.split(',') || ['speed', 'address', 'totalDistance', 'course']}
+ onChange={(_, option) => {
+ setAttributes({ ...attributes, positionItems: option.join(',') });
+ }}
+ filterOptions={(options, params) => {
+ const filtered = filter(options, params);
+ if (params.inputValue && !filtered.includes(params.inputValue)) {
+ filtered.push(params.inputValue);
+ }
+ return filtered;
+ }}
+ renderInput={(params) => (
+ <TextField
+ {...params}
+ label={t('attributePopupInfo')}
+ />
+ )}
+ />
+ <FormControl>
+ <InputLabel>{t('mapLiveRoutes')}</InputLabel>
+ <Select
+ label={t('mapLiveRoutes')}
+ value={attributes.mapLiveRoutes || 'none'}
+ onChange={(e) => setAttributes({ ...attributes, mapLiveRoutes: e.target.value })}
+ >
+ <MenuItem value="none">{t('sharedDisabled')}</MenuItem>
+ <MenuItem value="selected">{t('deviceSelected')}</MenuItem>
+ <MenuItem value="all">{t('notificationAlways')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormControl>
+ <InputLabel>{t('mapDirection')}</InputLabel>
+ <Select
+ label={t('mapDirection')}
+ value={attributes.mapDirection || 'selected'}
+ onChange={(e) => setAttributes({ ...attributes, mapDirection: e.target.value })}
+ >
+ <MenuItem value="none">{t('sharedDisabled')}</MenuItem>
+ <MenuItem value="selected">{t('deviceSelected')}</MenuItem>
+ <MenuItem value="all">{t('notificationAlways')}</MenuItem>
+ </Select>
+ </FormControl>
+ <FormGroup>
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapGeofences') ? attributes.mapGeofences : true}
+ onChange={(e) => setAttributes({ ...attributes, mapGeofences: e.target.checked })}
+ />
+ )}
+ label={t('attributeShowGeofences')}
+ />
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapFollow') ? attributes.mapFollow : false}
+ onChange={(e) => setAttributes({ ...attributes, mapFollow: e.target.checked })}
+ />
+ )}
+ label={t('deviceFollow')}
+ />
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapCluster') ? attributes.mapCluster : true}
+ onChange={(e) => setAttributes({ ...attributes, mapCluster: e.target.checked })}
+ />
+ )}
+ label={t('mapClustering')}
+ />
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={attributes.hasOwnProperty('mapOnSelect') ? attributes.mapOnSelect : true}
+ onChange={(e) => setAttributes({ ...attributes, mapOnSelect: e.target.checked })}
+ />
+ )}
+ label={t('mapOnSelect')}
+ />
+ </FormGroup>
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('deviceTitle')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ value={attributes.devicePrimary || 'name'}
+ onChange={(e) => setAttributes({ ...attributes, devicePrimary: e.target.value })}
+ data={deviceFields}
+ titleGetter={(it) => t(it.name)}
+ label={t('devicePrimaryInfo')}
+ />
+ <SelectField
+ value={attributes.deviceSecondary}
+ onChange={(e) => setAttributes({ ...attributes, deviceSecondary: e.target.value })}
+ data={deviceFields}
+ titleGetter={(it) => t(it.name)}
+ label={t('deviceSecondaryInfo')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedSound')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <SelectField
+ multiple
+ value={attributes.soundEvents?.split(',') || []}
+ onChange={(e) => setAttributes({ ...attributes, soundEvents: e.target.value.join(',') })}
+ endpoint="/api/notifications/types"
+ keyGetter={(it) => it.type}
+ titleGetter={(it) => t(prefixString('event', it.type))}
+ label={t('eventsSoundEvents')}
+ />
+ <SelectField
+ multiple
+ value={attributes.soundAlarms?.split(',') || ['sos']}
+ onChange={(e) => setAttributes({ ...attributes, soundAlarms: e.target.value.join(',') })}
+ data={alarms}
+ keyGetter={(it) => it.key}
+ label={t('eventsSoundAlarms')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ </>
+ )}
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('userToken')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ label={t('userExpirationTime')}
+ type="date"
+ value={tokenExpiration}
+ onChange={(e) => {
+ setTokenExpiration(e.target.value);
+ setToken(null);
+ }}
+ />
+ <FormControl>
+ <OutlinedInput
+ multiline
+ rows={6}
+ readOnly
+ type="text"
+ value={token || ''}
+ endAdornment={(
+ <InputAdornment position="end">
+ <div className={classes.verticalActions}>
+ <IconButton size="small" edge="end" onClick={generateToken} disabled={!!token}>
+ <CachedIcon fontSize="small" />
+ </IconButton>
+ <IconButton size="small" edge="end" onClick={() => navigator.clipboard.writeText(token)} disabled={!token}>
+ <ContentCopyIcon fontSize="small" />
+ </IconButton>
+ </div>
+ </InputAdornment>
+ )}
+ />
+ </FormControl>
+ </AccordionDetails>
+ </Accordion>
+ {!readonly && (
+ <>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedInfoTitle')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={versionApp}
+ label={t('settingsAppVersion')}
+ disabled
+ />
+ <TextField
+ value={versionServer || '-'}
+ label={t('settingsServerVersion')}
+ disabled
+ />
+ <TextField
+ value={socket ? t('deviceStatusOnline') : t('deviceStatusOffline')}
+ label={t('settingsConnection')}
+ disabled
+ />
+ </AccordionDetails>
+ </Accordion>
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={handleSave}
+ >
+ {t('sharedSave')}
+ </Button>
+ </div>
+ </>
+ )}
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default PreferencesPage;
diff --git a/src/settings/ServerPage.jsx b/src/settings/ServerPage.jsx
new file mode 100644
index 00000000..0ac76334
--- /dev/null
+++ b/src/settings/ServerPage.jsx
@@ -0,0 +1,316 @@
+import React, { useState } from 'react';
+import TextField from '@mui/material/TextField';
+
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Button,
+ FormControl,
+ Container,
+ Checkbox,
+ FormControlLabel,
+ InputLabel,
+ Select,
+ MenuItem,
+ FormGroup,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { useNavigate } from 'react-router-dom';
+import { useDispatch, useSelector } from 'react-redux';
+import { DropzoneArea } from 'react-mui-dropzone';
+import { sessionActions } from '../store';
+import EditAttributesAccordion from './components/EditAttributesAccordion';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import SelectField from '../common/components/SelectField';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import useCommonDeviceAttributes from '../common/attributes/useCommonDeviceAttributes';
+import useCommonUserAttributes from '../common/attributes/useCommonUserAttributes';
+import { useCatch } from '../reactHelper';
+import useServerAttributes from '../common/attributes/useServerAttributes';
+import useMapStyles from '../map/core/useMapStyles';
+import { map } from '../map/core/MapView';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const ServerPage = () => {
+ const classes = useSettingsStyles();
+ const navigate = useNavigate();
+ const dispatch = useDispatch();
+ const t = useTranslation();
+
+ const mapStyles = useMapStyles();
+ const commonUserAttributes = useCommonUserAttributes(t);
+ const commonDeviceAttributes = useCommonDeviceAttributes(t);
+ const serverAttributes = useServerAttributes(t);
+
+ const original = useSelector((state) => state.session.server);
+ const [item, setItem] = useState({ ...original });
+
+ const handleFiles = useCatch(async (files) => {
+ if (files.length > 0) {
+ const file = files[0];
+ const response = await fetch(`/api/server/file/${file.path}`, {
+ method: 'POST',
+ body: file,
+ });
+ if (!response.ok) {
+ throw Error(await response.text());
+ }
+ }
+ });
+
+ const handleSave = useCatch(async () => {
+ const response = await fetch('/api/server', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(item),
+ });
+
+ if (response.ok) {
+ dispatch(sessionActions.updateServer(await response.json()));
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'settingsServer']}>
+ <Container maxWidth="xs" className={classes.container}>
+ {item && (
+ <>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedPreferences')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={item.mapUrl || ''}
+ onChange={(event) => setItem({ ...item, mapUrl: event.target.value })}
+ label={t('mapCustomLabel')}
+ />
+ <TextField
+ value={item.overlayUrl || ''}
+ onChange={(event) => setItem({ ...item, overlayUrl: event.target.value })}
+ label={t('mapOverlayCustom')}
+ />
+ <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={(event) => setItem({ ...item, coordinateFormat: event.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.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.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.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.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.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={(event) => setItem({ ...item, poiLayer: event.target.value })}
+ label={t('mapPoiLayer')}
+ />
+ <TextField
+ value={item.announcement || ''}
+ onChange={(event) => setItem({ ...item, announcement: event.target.value })}
+ label={t('serverAnnouncement')}
+ />
+ <FormGroup>
+ <FormControlLabel
+ control={<Checkbox checked={item.twelveHourFormat} onChange={(event) => setItem({ ...item, twelveHourFormat: event.target.checked })} />}
+ label={t('settingsTwelveHourFormat')}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.forceSettings} onChange={(event) => setItem({ ...item, forceSettings: event.target.checked })} />}
+ label={t('serverForceSettings')}
+ />
+ </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={(event) => setItem({ ...item, latitude: Number(event.target.value) })}
+ label={t('positionLatitude')}
+ />
+ <TextField
+ type="number"
+ value={item.longitude || 0}
+ onChange={(event) => setItem({ ...item, longitude: Number(event.target.value) })}
+ label={t('positionLongitude')}
+ />
+ <TextField
+ type="number"
+ value={item.zoom || 0}
+ onChange={(event) => setItem({ ...item, zoom: Number(event.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}>
+ <FormGroup>
+ <FormControlLabel
+ control={<Checkbox checked={item.registration} onChange={(event) => setItem({ ...item, registration: event.target.checked })} />}
+ label={t('serverRegistration')}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.readonly} onChange={(event) => setItem({ ...item, readonly: event.target.checked })} />}
+ label={t('serverReadonly')}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.deviceReadonly} onChange={(event) => setItem({ ...item, deviceReadonly: event.target.checked })} />}
+ label={t('userDeviceReadonly')}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.limitCommands} onChange={(event) => setItem({ ...item, limitCommands: event.target.checked })} />}
+ label={t('userLimitCommands')}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.disableReports} onChange={(event) => setItem({ ...item, disableReports: event.target.checked })} />}
+ label={t('userDisableReports')}
+ />
+ <FormControlLabel
+ control={<Checkbox checked={item.fixedEmail} onChange={(e) => setItem({ ...item, fixedEmail: e.target.checked })} />}
+ label={t('userFixedEmail')}
+ />
+ </FormGroup>
+ </AccordionDetails>
+ </Accordion>
+ <Accordion>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedFile')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <DropzoneArea
+ dropzoneText={t('sharedDropzoneText')}
+ filesLimit={1}
+ onChange={handleFiles}
+ showAlerts={false}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <EditAttributesAccordion
+ attributes={item.attributes}
+ setAttributes={(attributes) => setItem({ ...item, attributes })}
+ definitions={{ ...commonUserAttributes, ...commonDeviceAttributes, ...serverAttributes }}
+ />
+ </>
+ )}
+ <div className={classes.buttons}>
+ <Button type="button" color="primary" variant="outlined" onClick={() => navigate(-1)}>
+ {t('sharedCancel')}
+ </Button>
+ <Button type="button" color="primary" variant="contained" onClick={handleSave}>
+ {t('sharedSave')}
+ </Button>
+ </div>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default ServerPage;
diff --git a/src/settings/SharePage.jsx b/src/settings/SharePage.jsx
new file mode 100644
index 00000000..d16fe44d
--- /dev/null
+++ b/src/settings/SharePage.jsx
@@ -0,0 +1,109 @@
+import React, { useState } from 'react';
+import { useSelector } from 'react-redux';
+import { useNavigate, useParams } from 'react-router-dom';
+import dayjs from 'dayjs';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+ TextField,
+ Button,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import { useCatchCallback } from '../reactHelper';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const SharePage = () => {
+ const navigate = useNavigate();
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ const device = useSelector((state) => state.devices.items[id]);
+
+ const [expiration, setExpiration] = useState(dayjs().add(1, 'week').locale('en').format('YYYY-MM-DD'));
+ const [link, setLink] = useState();
+
+ const handleShare = useCatchCallback(async () => {
+ const expirationTime = dayjs(expiration).toISOString();
+ const response = await fetch('/api/devices/share', {
+ method: 'POST',
+ body: new URLSearchParams(`deviceId=${id}&expiration=${expirationTime}`),
+ });
+ if (response.ok) {
+ const token = await response.text();
+ setLink(`${window.location.origin}?token=${token}`);
+ } else {
+ throw Error(await response.text());
+ }
+ }, [id, expiration, setLink]);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['deviceShare']}>
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedRequired')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <TextField
+ value={device.name}
+ label={t('sharedDevice')}
+ disabled
+ />
+ <TextField
+ label={t('userExpirationTime')}
+ type="date"
+ value={(expiration && dayjs(expiration).locale('en').format('YYYY-MM-DD')) || '2099-01-01'}
+ onChange={(e) => setExpiration(dayjs(e.target.value, 'YYYY-MM-DD').locale('en').format())}
+ />
+ <Button
+ variant="outlined"
+ color="primary"
+ onClick={handleShare}
+ >
+ {t('reportShow')}
+ </Button>
+ <TextField
+ value={link || ''}
+ onChange={(e) => setLink(e.target.value)}
+ label={t('sharedLink')}
+ InputProps={{
+ readOnly: true,
+ }}
+ />
+ </AccordionDetails>
+ </Accordion>
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={() => navigator.clipboard?.writeText(link)}
+ disabled={!link}
+ >
+ {t('sharedCopy')}
+ </Button>
+ </div>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default SharePage;
diff --git a/src/settings/UserConnectionsPage.jsx b/src/settings/UserConnectionsPage.jsx
new file mode 100644
index 00000000..3ca0bdc1
--- /dev/null
+++ b/src/settings/UserConnectionsPage.jsx
@@ -0,0 +1,129 @@
+import React from 'react';
+import { useParams } from 'react-router-dom';
+import {
+ Accordion,
+ AccordionSummary,
+ AccordionDetails,
+ Typography,
+ Container,
+} from '@mui/material';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import LinkField from '../common/components/LinkField';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import SettingsMenu from './components/SettingsMenu';
+import { formatNotificationTitle } from '../common/util/formatter';
+import PageLayout from '../common/components/PageLayout';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const UserConnectionsPage = () => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ return (
+ <PageLayout
+ menu={<SettingsMenu />}
+ breadcrumbs={['settingsTitle', 'settingsUser', 'sharedConnections']}
+ >
+ <Container maxWidth="xs" className={classes.container}>
+ <Accordion defaultExpanded>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedConnections')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ <LinkField
+ endpointAll="/api/devices?all=true"
+ endpointLinked={`/api/devices?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="deviceId"
+ titleGetter={(it) => `${it.name} (${it.uniqueId})`}
+ label={t('deviceTitle')}
+ />
+ <LinkField
+ endpointAll="/api/groups?all=true"
+ endpointLinked={`/api/groups?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="groupId"
+ label={t('settingsGroups')}
+ />
+ <LinkField
+ endpointAll="/api/geofences?all=true"
+ endpointLinked={`/api/geofences?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="geofenceId"
+ label={t('sharedGeofences')}
+ />
+ <LinkField
+ endpointAll="/api/notifications?all=true"
+ endpointLinked={`/api/notifications?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="notificationId"
+ titleGetter={(it) => formatNotificationTitle(t, it, true)}
+ label={t('sharedNotifications')}
+ />
+ <LinkField
+ endpointAll="/api/calendars?all=true"
+ endpointLinked={`/api/calendars?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="calendarId"
+ label={t('sharedCalendars')}
+ />
+ <LinkField
+ endpointAll="/api/users?all=true"
+ endpointLinked={`/api/users?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="managedUserId"
+ label={t('settingsUsers')}
+ />
+ <LinkField
+ endpointAll="/api/attributes/computed?all=true"
+ endpointLinked={`/api/attributes/computed?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="attributeId"
+ titleGetter={(it) => it.description}
+ label={t('sharedComputedAttributes')}
+ />
+ <LinkField
+ endpointAll="/api/drivers?all=true"
+ endpointLinked={`/api/drivers?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="driverId"
+ titleGetter={(it) => `${it.name} (${it.uniqueId})`}
+ label={t('sharedDrivers')}
+ />
+ <LinkField
+ endpointAll="/api/commands?all=true"
+ endpointLinked={`/api/commands?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="commandId"
+ titleGetter={(it) => it.description}
+ label={t('sharedSavedCommands')}
+ />
+ <LinkField
+ endpointAll="/api/maintenance?all=true"
+ endpointLinked={`/api/maintenance?userId=${id}`}
+ baseId={id}
+ keyBase="userId"
+ keyLink="maintenanceId"
+ label={t('sharedMaintenance')}
+ />
+ </AccordionDetails>
+ </Accordion>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default UserConnectionsPage;
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;
diff --git a/src/settings/UsersPage.jsx b/src/settings/UsersPage.jsx
new file mode 100644
index 00000000..2941965b
--- /dev/null
+++ b/src/settings/UsersPage.jsx
@@ -0,0 +1,130 @@
+import React, { useState } from 'react';
+import { useNavigate } from 'react-router-dom';
+import {
+ Table, TableRow, TableCell, TableHead, TableBody, Switch, TableFooter, FormControlLabel,
+} from '@mui/material';
+import LoginIcon from '@mui/icons-material/Login';
+import LinkIcon from '@mui/icons-material/Link';
+import { useCatch, useEffectAsync } from '../reactHelper';
+import { formatBoolean, formatTime } from '../common/util/formatter';
+import { useTranslation } from '../common/components/LocalizationProvider';
+import PageLayout from '../common/components/PageLayout';
+import SettingsMenu from './components/SettingsMenu';
+import CollectionFab from './components/CollectionFab';
+import CollectionActions from './components/CollectionActions';
+import TableShimmer from '../common/components/TableShimmer';
+import { useManager } from '../common/util/permissions';
+import SearchHeader, { filterByKeyword } from './components/SearchHeader';
+import { usePreference } from '../common/util/preferences';
+import useSettingsStyles from './common/useSettingsStyles';
+
+const UsersPage = () => {
+ const classes = useSettingsStyles();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const manager = useManager();
+
+ const hours12 = usePreference('twelveHourFormat');
+
+ const [timestamp, setTimestamp] = useState(Date.now());
+ const [items, setItems] = useState([]);
+ const [searchKeyword, setSearchKeyword] = useState('');
+ const [loading, setLoading] = useState(false);
+ const [temporary, setTemporary] = useState(false);
+
+ const handleLogin = useCatch(async (userId) => {
+ const response = await fetch(`/api/session/${userId}`);
+ if (response.ok) {
+ window.location.replace('/');
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ const actionLogin = {
+ key: 'login',
+ title: t('loginLogin'),
+ icon: <LoginIcon fontSize="small" />,
+ handler: handleLogin,
+ };
+
+ const actionConnections = {
+ key: 'connections',
+ title: t('sharedConnections'),
+ icon: <LinkIcon fontSize="small" />,
+ handler: (userId) => navigate(`/settings/user/${userId}/connections`),
+ };
+
+ useEffectAsync(async () => {
+ setLoading(true);
+ try {
+ const response = await fetch('/api/users');
+ if (response.ok) {
+ setItems(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } finally {
+ setLoading(false);
+ }
+ }, [timestamp]);
+
+ return (
+ <PageLayout menu={<SettingsMenu />} breadcrumbs={['settingsTitle', 'settingsUsers']}>
+ <SearchHeader keyword={searchKeyword} setKeyword={setSearchKeyword} />
+ <Table className={classes.table}>
+ <TableHead>
+ <TableRow>
+ <TableCell>{t('sharedName')}</TableCell>
+ <TableCell>{t('userEmail')}</TableCell>
+ <TableCell>{t('userAdmin')}</TableCell>
+ <TableCell>{t('sharedDisabled')}</TableCell>
+ <TableCell>{t('userExpirationTime')}</TableCell>
+ <TableCell className={classes.columnAction} />
+ </TableRow>
+ </TableHead>
+ <TableBody>
+ {!loading ? items.filter((u) => temporary || !u.temporary).filter(filterByKeyword(searchKeyword)).map((item) => (
+ <TableRow key={item.id}>
+ <TableCell>{item.name}</TableCell>
+ <TableCell>{item.email}</TableCell>
+ <TableCell>{formatBoolean(item.administrator, t)}</TableCell>
+ <TableCell>{formatBoolean(item.disabled, t)}</TableCell>
+ <TableCell>{formatTime(item.expirationTime, 'date', hours12)}</TableCell>
+ <TableCell className={classes.columnAction} padding="none">
+ <CollectionActions
+ itemId={item.id}
+ editPath="/settings/user"
+ endpoint="users"
+ setTimestamp={setTimestamp}
+ customActions={manager ? [actionLogin, actionConnections] : [actionConnections]}
+ />
+ </TableCell>
+ </TableRow>
+ )) : (<TableShimmer columns={6} endAction />)}
+ </TableBody>
+ <TableFooter>
+ <TableRow>
+ <TableCell colSpan={6} align="right">
+ <FormControlLabel
+ control={(
+ <Switch
+ value={temporary}
+ onChange={(e) => setTemporary(e.target.checked)}
+ size="small"
+ />
+ )}
+ label={t('userTemporary')}
+ labelPlacement="start"
+ />
+ </TableCell>
+ </TableRow>
+ </TableFooter>
+ </Table>
+ <CollectionFab editPath="/settings/user" />
+ </PageLayout>
+ );
+};
+
+export default UsersPage;
diff --git a/src/settings/common/useSettingsStyles.js b/src/settings/common/useSettingsStyles.js
new file mode 100644
index 00000000..b276e0b7
--- /dev/null
+++ b/src/settings/common/useSettingsStyles.js
@@ -0,0 +1,33 @@
+import { makeStyles } from '@mui/styles';
+
+export default makeStyles((theme) => ({
+ table: {
+ marginBottom: theme.spacing(10),
+ },
+ columnAction: {
+ width: '1%',
+ paddingRight: theme.spacing(1),
+ },
+ container: {
+ marginTop: theme.spacing(2),
+ },
+ buttons: {
+ marginTop: theme.spacing(2),
+ marginBottom: theme.spacing(2),
+ display: 'flex',
+ justifyContent: 'space-evenly',
+ '& > *': {
+ flexBasis: '33%',
+ },
+ },
+ details: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ paddingBottom: theme.spacing(3),
+ },
+ verticalActions: {
+ display: 'flex',
+ flexDirection: 'column',
+ },
+}));
diff --git a/src/settings/components/AddAttributeDialog.jsx b/src/settings/components/AddAttributeDialog.jsx
new file mode 100644
index 00000000..86ff64ea
--- /dev/null
+++ b/src/settings/components/AddAttributeDialog.jsx
@@ -0,0 +1,104 @@
+import React, { useState } from 'react';
+import {
+ Button, Dialog, DialogActions, DialogContent, FormControl, InputLabel, MenuItem, Select, TextField, Autocomplete,
+} from '@mui/material';
+
+import { createFilterOptions } from '@mui/material/useAutocomplete';
+import { makeStyles } from '@mui/styles';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+
+const useStyles = makeStyles((theme) => ({
+ details: {
+ display: 'flex',
+ flexDirection: 'column',
+ gap: theme.spacing(2),
+ paddingBottom: theme.spacing(1),
+ paddingTop: theme.spacing(3),
+ },
+}));
+
+const AddAttributeDialog = ({ open, onResult, definitions }) => {
+ const classes = useStyles();
+ const t = useTranslation();
+
+ const filter = createFilterOptions({
+ stringify: (option) => option.name,
+ });
+
+ const options = Object.entries(definitions).map(([key, value]) => ({
+ key,
+ name: value.name,
+ type: value.type,
+ }));
+
+ const [key, setKey] = useState();
+ const [type, setType] = useState('string');
+
+ return (
+ <Dialog open={open} fullWidth maxWidth="xs">
+ <DialogContent className={classes.details}>
+ <Autocomplete
+ onChange={(_, option) => {
+ setKey(option && typeof option === 'object' ? option.key : option);
+ if (option && option.type) {
+ setType(option.type);
+ }
+ }}
+ filterOptions={(options, params) => {
+ const filtered = filter(options, params);
+ if (params.inputValue) {
+ filtered.push({
+ key: params.inputValue,
+ name: params.inputValue,
+ });
+ }
+ return filtered;
+ }}
+ options={options}
+ getOptionLabel={(option) => (option && typeof option === 'object' ? option.name : option)}
+ renderOption={(props, option) => (
+ <li {...props}>
+ {option.name}
+ </li>
+ )}
+ renderInput={(params) => (
+ <TextField {...params} label={t('sharedAttribute')} />
+ )}
+ freeSolo
+ />
+ <FormControl
+ fullWidth
+ disabled={key in definitions}
+ >
+ <InputLabel>{t('sharedType')}</InputLabel>
+ <Select
+ label={t('sharedType')}
+ value={type}
+ onChange={(e) => setType(e.target.value)}
+ >
+ <MenuItem value="string">{t('sharedTypeString')}</MenuItem>
+ <MenuItem value="number">{t('sharedTypeNumber')}</MenuItem>
+ <MenuItem value="boolean">{t('sharedTypeBoolean')}</MenuItem>
+ </Select>
+ </FormControl>
+ </DialogContent>
+ <DialogActions>
+ <Button
+ color="primary"
+ disabled={!key}
+ onClick={() => onResult({ key, type })}
+ >
+ {t('sharedAdd')}
+ </Button>
+ <Button
+ autoFocus
+ onClick={() => onResult(null)}
+ >
+ {t('sharedCancel')}
+ </Button>
+ </DialogActions>
+ </Dialog>
+ );
+};
+
+export default AddAttributeDialog;
diff --git a/src/settings/components/BaseCommandView.jsx b/src/settings/components/BaseCommandView.jsx
new file mode 100644
index 00000000..bb70c3b9
--- /dev/null
+++ b/src/settings/components/BaseCommandView.jsx
@@ -0,0 +1,79 @@
+import React, { useEffect, useState } from 'react';
+import { useSelector } from 'react-redux';
+import {
+ TextField, FormControlLabel, Checkbox,
+} from '@mui/material';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import SelectField from '../../common/components/SelectField';
+import { prefixString } from '../../common/util/stringUtils';
+import useCommandAttributes from '../../common/attributes/useCommandAttributes';
+
+const BaseCommandView = ({ deviceId, item, setItem }) => {
+ const t = useTranslation();
+
+ const textEnabled = useSelector((state) => state.session.server.textEnabled);
+
+ const availableAttributes = useCommandAttributes(t);
+
+ const [attributes, setAttributes] = useState([]);
+
+ useEffect(() => {
+ if (item && item.type) {
+ setAttributes(availableAttributes[item.type] || []);
+ } else {
+ setAttributes([]);
+ }
+ }, [availableAttributes, item]);
+
+ return (
+ <>
+ <SelectField
+ value={item.type}
+ onChange={(e) => setItem({ ...item, type: e.target.value, attributes: {} })}
+ endpoint={deviceId ? `/api/commands/types?${new URLSearchParams({ deviceId }).toString()}` : '/api/commands/types'}
+ keyGetter={(it) => it.type}
+ titleGetter={(it) => t(prefixString('command', it.type))}
+ label={t('sharedType')}
+ />
+ {attributes.map(({ key, name, type }) => {
+ if (type === 'boolean') {
+ return (
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={item.attributes[key]}
+ onChange={(e) => {
+ const updateItem = { ...item, attributes: { ...item.attributes } };
+ updateItem.attributes[key] = e.target.checked;
+ setItem(updateItem);
+ }}
+ />
+ )}
+ label={name}
+ />
+ );
+ }
+ return (
+ <TextField
+ type={type === 'number' ? 'number' : 'text'}
+ value={item.attributes[key]}
+ onChange={(e) => {
+ const updateItem = { ...item, attributes: { ...item.attributes } };
+ updateItem.attributes[key] = type === 'number' ? Number(e.target.value) : e.target.value;
+ setItem(updateItem);
+ }}
+ label={name}
+ />
+ );
+ })}
+ {textEnabled && (
+ <FormControlLabel
+ control={<Checkbox checked={item.textChannel} onChange={(event) => setItem({ ...item, textChannel: event.target.checked })} />}
+ label={t('commandSendSms')}
+ />
+ )}
+ </>
+ );
+};
+
+export default BaseCommandView;
diff --git a/src/settings/components/CollectionActions.jsx b/src/settings/components/CollectionActions.jsx
new file mode 100644
index 00000000..666052d5
--- /dev/null
+++ b/src/settings/components/CollectionActions.jsx
@@ -0,0 +1,104 @@
+import React, { useState } from 'react';
+import {
+ IconButton, Menu, MenuItem, useMediaQuery, useTheme,
+} from '@mui/material';
+import Tooltip from '@mui/material/Tooltip';
+import MoreVertIcon from '@mui/icons-material/MoreVert';
+import EditIcon from '@mui/icons-material/Edit';
+import DeleteIcon from '@mui/icons-material/Delete';
+import { useNavigate } from 'react-router-dom';
+import { makeStyles } from '@mui/styles';
+import RemoveDialog from '../../common/components/RemoveDialog';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+
+const useStyles = makeStyles(() => ({
+ row: {
+ display: 'flex',
+ },
+}));
+
+const CollectionActions = ({
+ itemId, editPath, endpoint, setTimestamp, customActions, readonly,
+}) => {
+ const theme = useTheme();
+ const classes = useStyles();
+ const navigate = useNavigate();
+ const t = useTranslation();
+
+ const phone = useMediaQuery(theme.breakpoints.down('sm'));
+
+ const [menuAnchorEl, setMenuAnchorEl] = useState(null);
+ const [removing, setRemoving] = useState(false);
+
+ const handleEdit = () => {
+ navigate(`${editPath}/${itemId}`);
+ setMenuAnchorEl(null);
+ };
+
+ const handleRemove = () => {
+ setRemoving(true);
+ setMenuAnchorEl(null);
+ };
+
+ const handleCustom = (action) => {
+ action.handler(itemId);
+ setMenuAnchorEl(null);
+ };
+
+ const handleRemoveResult = (removed) => {
+ setRemoving(false);
+ if (removed) {
+ setTimestamp(Date.now());
+ }
+ };
+
+ return (
+ <>
+ {phone ? (
+ <>
+ <IconButton size="small" onClick={(event) => setMenuAnchorEl(event.currentTarget)}>
+ <MoreVertIcon fontSize="small" />
+ </IconButton>
+ <Menu open={!!menuAnchorEl} anchorEl={menuAnchorEl} onClose={() => setMenuAnchorEl(null)}>
+ {customActions && customActions.map((action) => (
+ <MenuItem onClick={() => handleCustom(action)} key={action.key}>{action.title}</MenuItem>
+ ))}
+ {!readonly && (
+ <>
+ <MenuItem onClick={handleEdit}>{t('sharedEdit')}</MenuItem>
+ <MenuItem onClick={handleRemove}>{t('sharedRemove')}</MenuItem>
+ </>
+ )}
+ </Menu>
+ </>
+ ) : (
+ <div className={classes.row}>
+ {customActions && customActions.map((action) => (
+ <Tooltip title={action.title} key={action.key}>
+ <IconButton size="small" onClick={() => handleCustom(action)}>
+ {action.icon}
+ </IconButton>
+ </Tooltip>
+ ))}
+ {!readonly && (
+ <>
+ <Tooltip title={t('sharedEdit')}>
+ <IconButton size="small" onClick={handleEdit}>
+ <EditIcon fontSize="small" />
+ </IconButton>
+ </Tooltip>
+ <Tooltip title={t('sharedRemove')}>
+ <IconButton size="small" onClick={handleRemove}>
+ <DeleteIcon fontSize="small" />
+ </IconButton>
+ </Tooltip>
+ </>
+ )}
+ </div>
+ )}
+ <RemoveDialog style={{ transform: 'none' }} open={removing} endpoint={endpoint} itemId={itemId} onResult={handleRemoveResult} />
+ </>
+ );
+};
+
+export default CollectionActions;
diff --git a/src/settings/components/CollectionFab.jsx b/src/settings/components/CollectionFab.jsx
new file mode 100644
index 00000000..3c1fa783
--- /dev/null
+++ b/src/settings/components/CollectionFab.jsx
@@ -0,0 +1,35 @@
+import React from 'react';
+import { Fab } from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import AddIcon from '@mui/icons-material/Add';
+import { useNavigate } from 'react-router-dom';
+import { useRestriction } from '../../common/util/permissions';
+
+const useStyles = makeStyles((theme) => ({
+ fab: {
+ position: 'fixed',
+ bottom: theme.spacing(2),
+ right: theme.spacing(2),
+ [theme.breakpoints.down('md')]: {
+ bottom: `calc(${theme.dimensions.bottomBarHeight}px + ${theme.spacing(2)})`,
+ },
+ },
+}));
+
+const CollectionFab = ({ editPath, disabled }) => {
+ const classes = useStyles();
+ const navigate = useNavigate();
+
+ const readonly = useRestriction('readonly');
+
+ if (!readonly && !disabled) {
+ return (
+ <Fab size="medium" color="primary" className={classes.fab} onClick={() => navigate(editPath)}>
+ <AddIcon />
+ </Fab>
+ );
+ }
+ return '';
+};
+
+export default CollectionFab;
diff --git a/src/settings/components/EditAttributesAccordion.jsx b/src/settings/components/EditAttributesAccordion.jsx
new file mode 100644
index 00000000..4d4ae254
--- /dev/null
+++ b/src/settings/components/EditAttributesAccordion.jsx
@@ -0,0 +1,217 @@
+import React, { useState } from 'react';
+
+import {
+ Button,
+ Checkbox,
+ OutlinedInput,
+ FormControl,
+ FormControlLabel,
+ Grid,
+ IconButton,
+ InputAdornment,
+ InputLabel,
+ Accordion,
+ AccordionSummary,
+ Typography,
+ AccordionDetails,
+} from '@mui/material';
+import CloseIcon from '@mui/icons-material/Close';
+import AddIcon from '@mui/icons-material/Add';
+import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
+import AddAttributeDialog from './AddAttributeDialog';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import { useAttributePreference } from '../../common/util/preferences';
+import {
+ distanceFromMeters, distanceToMeters, distanceUnitString, speedFromKnots, speedToKnots, speedUnitString, volumeFromLiters, volumeToLiters, volumeUnitString,
+} from '../../common/util/converter';
+import useFeatures from '../../common/util/useFeatures';
+import useSettingsStyles from '../common/useSettingsStyles';
+
+const EditAttributesAccordion = ({ attribute, attributes, setAttributes, definitions, focusAttribute }) => {
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const features = useFeatures();
+
+ const speedUnit = useAttributePreference('speedUnit');
+ const distanceUnit = useAttributePreference('distanceUnit');
+ const volumeUnit = useAttributePreference('volumeUnit');
+
+ const [addDialogShown, setAddDialogShown] = useState(false);
+
+ const updateAttribute = (key, value, type, subtype) => {
+ const updatedAttributes = { ...attributes };
+ switch (subtype) {
+ case 'speed':
+ updatedAttributes[key] = speedToKnots(Number(value), speedUnit);
+ break;
+ case 'distance':
+ updatedAttributes[key] = distanceToMeters(Number(value), distanceUnit);
+ break;
+ case 'volume':
+ updatedAttributes[key] = volumeToLiters(Number(value), volumeUnit);
+ break;
+ default:
+ updatedAttributes[key] = type === 'number' ? Number(value) : value;
+ break;
+ }
+ setAttributes(updatedAttributes);
+ };
+
+ const deleteAttribute = (key) => {
+ const updatedAttributes = { ...attributes };
+ delete updatedAttributes[key];
+ setAttributes(updatedAttributes);
+ };
+
+ const getAttributeName = (key, subtype) => {
+ const definition = definitions[key];
+ const name = definition ? definition.name : key;
+ switch (subtype) {
+ case 'speed':
+ return `${name} (${speedUnitString(speedUnit, t)})`;
+ case 'distance':
+ return `${name} (${distanceUnitString(distanceUnit, t)})`;
+ case 'volume':
+ return `${name} (${volumeUnitString(volumeUnit, t)})`;
+ default:
+ return name;
+ }
+ };
+
+ const getAttributeType = (value) => {
+ if (typeof value === 'number') {
+ return 'number';
+ } if (typeof value === 'boolean') {
+ return 'boolean';
+ }
+ return 'string';
+ };
+
+ const getAttributeSubtype = (key) => {
+ const definition = definitions[key];
+ return definition && definition.subtype;
+ };
+
+ const getDisplayValue = (value, subtype) => {
+ if (value) {
+ switch (subtype) {
+ case 'speed':
+ return speedFromKnots(value, speedUnit);
+ case 'distance':
+ return distanceFromMeters(value, distanceUnit);
+ case 'volume':
+ return volumeFromLiters(value, volumeUnit);
+ default:
+ return value;
+ }
+ }
+ return '';
+ };
+
+ const convertToList = (attributes) => {
+ const booleanList = [];
+ const otherList = [];
+ const excludeAttributes = ['speedUnit', 'distanceUnit', 'volumeUnit', 'timezone'];
+ Object.keys(attributes || []).filter((key) => !excludeAttributes.includes(key)).forEach((key) => {
+ const value = attributes[key];
+ const type = getAttributeType(value);
+ const subtype = getAttributeSubtype(key);
+ if (type === 'boolean') {
+ booleanList.push({
+ key, value, type, subtype,
+ });
+ } else {
+ otherList.push({
+ key, value, type, subtype,
+ });
+ }
+ });
+ return [...otherList, ...booleanList];
+ };
+
+ const handleAddResult = (definition) => {
+ setAddDialogShown(false);
+ if (definition) {
+ switch (definition.type) {
+ case 'number':
+ updateAttribute(definition.key, 0);
+ break;
+ case 'boolean':
+ updateAttribute(definition.key, false);
+ break;
+ default:
+ updateAttribute(definition.key, '');
+ break;
+ }
+ }
+ };
+
+ return features.disableAttributes ? '' : (
+ <Accordion defaultExpanded={!!attribute}>
+ <AccordionSummary expandIcon={<ExpandMoreIcon />}>
+ <Typography variant="subtitle1">
+ {t('sharedAttributes')}
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails className={classes.details}>
+ {convertToList(attributes).map(({
+ key, value, type, subtype,
+ }) => {
+ if (type === 'boolean') {
+ return (
+ <Grid container direction="row" justifyContent="space-between" key={key}>
+ <FormControlLabel
+ control={(
+ <Checkbox
+ checked={value}
+ onChange={(e) => updateAttribute(key, e.target.checked)}
+ />
+ )}
+ label={getAttributeName(key, subtype)}
+ />
+ <IconButton size="small" className={classes.removeButton} onClick={() => deleteAttribute(key)}>
+ <CloseIcon fontSize="small" />
+ </IconButton>
+ </Grid>
+ );
+ }
+ return (
+ <FormControl key={key}>
+ <InputLabel>{getAttributeName(key, subtype)}</InputLabel>
+ <OutlinedInput
+ label={getAttributeName(key, subtype)}
+ type={type === 'number' ? 'number' : 'text'}
+ value={getDisplayValue(value, subtype)}
+ onChange={(e) => updateAttribute(key, e.target.value, type, subtype)}
+ autoFocus={focusAttribute === key}
+ endAdornment={(
+ <InputAdornment position="end">
+ <IconButton size="small" edge="end" onClick={() => deleteAttribute(key)}>
+ <CloseIcon fontSize="small" />
+ </IconButton>
+ </InputAdornment>
+ )}
+ />
+ </FormControl>
+ );
+ })}
+ <Button
+ variant="outlined"
+ color="primary"
+ onClick={() => setAddDialogShown(true)}
+ startIcon={<AddIcon />}
+ >
+ {t('sharedAdd')}
+ </Button>
+ <AddAttributeDialog
+ open={addDialogShown}
+ onResult={handleAddResult}
+ definitions={definitions}
+ />
+ </AccordionDetails>
+ </Accordion>
+ );
+};
+
+export default EditAttributesAccordion;
diff --git a/src/settings/components/EditItemView.jsx b/src/settings/components/EditItemView.jsx
new file mode 100644
index 00000000..61bc4161
--- /dev/null
+++ b/src/settings/components/EditItemView.jsx
@@ -0,0 +1,101 @@
+import React from 'react';
+import { useNavigate, useParams } from 'react-router-dom';
+import {
+ Container, Button, Accordion, AccordionDetails, AccordionSummary, Skeleton, Typography, TextField,
+} from '@mui/material';
+import { useCatch, useEffectAsync } from '../../reactHelper';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import PageLayout from '../../common/components/PageLayout';
+import useSettingsStyles from '../common/useSettingsStyles';
+
+const EditItemView = ({
+ children, endpoint, item, setItem, defaultItem, validate, onItemSaved, menu, breadcrumbs,
+}) => {
+ const navigate = useNavigate();
+ const classes = useSettingsStyles();
+ const t = useTranslation();
+
+ const { id } = useParams();
+
+ useEffectAsync(async () => {
+ if (!item) {
+ if (id) {
+ const response = await fetch(`/api/${endpoint}/${id}`);
+ if (response.ok) {
+ setItem(await response.json());
+ } else {
+ throw Error(await response.text());
+ }
+ } else {
+ setItem(defaultItem || {});
+ }
+ }
+ }, [id, item, defaultItem]);
+
+ const handleSave = useCatch(async () => {
+ let url = `/api/${endpoint}`;
+ if (id) {
+ url += `/${id}`;
+ }
+
+ const response = await fetch(url, {
+ method: !id ? 'POST' : 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(item),
+ });
+
+ if (response.ok) {
+ if (onItemSaved) {
+ onItemSaved(await response.json());
+ }
+ navigate(-1);
+ } else {
+ throw Error(await response.text());
+ }
+ });
+
+ return (
+ <PageLayout menu={menu} breadcrumbs={breadcrumbs}>
+ <Container maxWidth="xs" className={classes.container}>
+ {item ? children : (
+ <Accordion defaultExpanded>
+ <AccordionSummary>
+ <Typography variant="subtitle1">
+ <Skeleton width="10em" />
+ </Typography>
+ </AccordionSummary>
+ <AccordionDetails>
+ {[...Array(3)].map((_, i) => (
+ <Skeleton key={-i} width="100%">
+ <TextField />
+ </Skeleton>
+ ))}
+ </AccordionDetails>
+ </Accordion>
+ )}
+ <div className={classes.buttons}>
+ <Button
+ type="button"
+ color="primary"
+ variant="outlined"
+ onClick={() => navigate(-1)}
+ disabled={!item}
+ >
+ {t('sharedCancel')}
+ </Button>
+ <Button
+ type="button"
+ color="primary"
+ variant="contained"
+ onClick={handleSave}
+ disabled={!item || !validate()}
+ >
+ {t('sharedSave')}
+ </Button>
+ </div>
+ </Container>
+ </PageLayout>
+ );
+};
+
+export default EditItemView;
diff --git a/src/settings/components/SearchHeader.jsx b/src/settings/components/SearchHeader.jsx
new file mode 100644
index 00000000..25757ed2
--- /dev/null
+++ b/src/settings/components/SearchHeader.jsx
@@ -0,0 +1,38 @@
+import React from 'react';
+import { TextField, useTheme, useMediaQuery } from '@mui/material';
+import makeStyles from '@mui/styles/makeStyles';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+
+export const filterByKeyword = (keyword) => (item) => !keyword || JSON.stringify(item).toLowerCase().includes(keyword.toLowerCase());
+
+const useStyles = makeStyles((theme) => ({
+ header: {
+ position: 'sticky',
+ left: 0,
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'stretch',
+ padding: theme.spacing(3, 2, 2),
+ },
+}));
+
+const SearchHeader = ({ keyword, setKeyword }) => {
+ const theme = useTheme();
+ const classes = useStyles();
+ const t = useTranslation();
+
+ const phone = useMediaQuery(theme.breakpoints.down('sm'));
+
+ return phone ? (
+ <div className={classes.header}>
+ <TextField
+ variant="outlined"
+ placeholder={t('sharedSearch')}
+ value={keyword}
+ onChange={(e) => setKeyword(e.target.value)}
+ />
+ </div>
+ ) : '';
+};
+
+export default SearchHeader;
diff --git a/src/settings/components/SettingsMenu.jsx b/src/settings/components/SettingsMenu.jsx
new file mode 100644
index 00000000..7085d47a
--- /dev/null
+++ b/src/settings/components/SettingsMenu.jsx
@@ -0,0 +1,173 @@
+import React from 'react';
+import {
+ Divider, List, ListItemButton, ListItemIcon, ListItemText,
+} from '@mui/material';
+import SettingsIcon from '@mui/icons-material/Settings';
+import CreateIcon from '@mui/icons-material/Create';
+import NotificationsIcon from '@mui/icons-material/Notifications';
+import FolderIcon from '@mui/icons-material/Folder';
+import PersonIcon from '@mui/icons-material/Person';
+import StorageIcon from '@mui/icons-material/Storage';
+import BuildIcon from '@mui/icons-material/Build';
+import PeopleIcon from '@mui/icons-material/People';
+import TodayIcon from '@mui/icons-material/Today';
+import PublishIcon from '@mui/icons-material/Publish';
+import SmartphoneIcon from '@mui/icons-material/Smartphone';
+import HelpIcon from '@mui/icons-material/Help';
+import CampaignIcon from '@mui/icons-material/Campaign';
+import { Link, useLocation } from 'react-router-dom';
+import { useSelector } from 'react-redux';
+import { useTranslation } from '../../common/components/LocalizationProvider';
+import {
+ useAdministrator, useManager, useRestriction,
+} from '../../common/util/permissions';
+import useFeatures from '../../common/util/useFeatures';
+
+const MenuItem = ({
+ title, link, icon, selected,
+}) => (
+ <ListItemButton key={link} component={Link} to={link} selected={selected}>
+ <ListItemIcon>{icon}</ListItemIcon>
+ <ListItemText primary={title} />
+ </ListItemButton>
+);
+
+const SettingsMenu = () => {
+ const t = useTranslation();
+ const location = useLocation();
+
+ const readonly = useRestriction('readonly');
+ const admin = useAdministrator();
+ const manager = useManager();
+ const userId = useSelector((state) => state.session.user.id);
+ const supportLink = useSelector((state) => state.session.server.attributes.support);
+
+ const features = useFeatures();
+
+ return (
+ <>
+ <List>
+ <MenuItem
+ title={t('sharedPreferences')}
+ link="/settings/preferences"
+ icon={<SettingsIcon />}
+ selected={location.pathname === '/settings/preferences'}
+ />
+ {!readonly && (
+ <>
+ <MenuItem
+ title={t('sharedNotifications')}
+ link="/settings/notifications"
+ icon={<NotificationsIcon />}
+ selected={location.pathname.startsWith('/settings/notification')}
+ />
+ <MenuItem
+ title={t('settingsUser')}
+ link={`/settings/user/${userId}`}
+ icon={<PersonIcon />}
+ selected={location.pathname === `/settings/user/${userId}`}
+ />
+ <MenuItem
+ title={t('deviceTitle')}
+ link="/settings/devices"
+ icon={<SmartphoneIcon />}
+ selected={location.pathname.startsWith('/settings/device')}
+ />
+ <MenuItem
+ title={t('sharedGeofences')}
+ link="/geofences"
+ icon={<CreateIcon />}
+ selected={location.pathname.startsWith('/settings/geofence')}
+ />
+ {!features.disableGroups && (
+ <MenuItem
+ title={t('settingsGroups')}
+ link="/settings/groups"
+ icon={<FolderIcon />}
+ selected={location.pathname.startsWith('/settings/group')}
+ />
+ )}
+ {!features.disableDrivers && (
+ <MenuItem
+ title={t('sharedDrivers')}
+ link="/settings/drivers"
+ icon={<PersonIcon />}
+ selected={location.pathname.startsWith('/settings/driver')}
+ />
+ )}
+ {!features.disableCalendars && (
+ <MenuItem
+ title={t('sharedCalendars')}
+ link="/settings/calendars"
+ icon={<TodayIcon />}
+ selected={location.pathname.startsWith('/settings/calendar')}
+ />
+ )}
+ {!features.disableComputedAttributes && (
+ <MenuItem
+ title={t('sharedComputedAttributes')}
+ link="/settings/attributes"
+ icon={<StorageIcon />}
+ selected={location.pathname.startsWith('/settings/attribute')}
+ />
+ )}
+ {!features.disableMaintenance && (
+ <MenuItem
+ title={t('sharedMaintenance')}
+ link="/settings/maintenances"
+ icon={<BuildIcon />}
+ selected={location.pathname.startsWith('/settings/maintenance')}
+ />
+ )}
+ {!features.disableSavedCommands && (
+ <MenuItem
+ title={t('sharedSavedCommands')}
+ link="/settings/commands"
+ icon={<PublishIcon />}
+ selected={location.pathname.startsWith('/settings/command')}
+ />
+ )}
+ {supportLink && (
+ <MenuItem
+ title={t('settingsSupport')}
+ link={supportLink}
+ icon={<HelpIcon />}
+ />
+ )}
+ </>
+ )}
+ </List>
+ {manager && (
+ <>
+ <Divider />
+ <List>
+ {admin && (
+ <>
+ <MenuItem
+ title={t('serverAnnouncement')}
+ link="/settings/announcement"
+ icon={<CampaignIcon />}
+ selected={location.pathname === '/settings/announcement'}
+ />
+ <MenuItem
+ title={t('settingsServer')}
+ link="/settings/server"
+ icon={<StorageIcon />}
+ selected={location.pathname === '/settings/server'}
+ />
+ </>
+ )}
+ <MenuItem
+ title={t('settingsUsers')}
+ link="/settings/users"
+ icon={<PeopleIcon />}
+ selected={location.pathname.startsWith('/settings/user') && location.pathname !== `/settings/user/${userId}`}
+ />
+ </List>
+ </>
+ )}
+ </>
+ );
+};
+
+export default SettingsMenu;