settings frontend bugfix + refactoring

This commit is contained in:
Roman 2024-04-12 11:36:30 +02:00
parent 3888e7fcde
commit b274cd4ad2
11 changed files with 217 additions and 126 deletions

@ -21,7 +21,7 @@ const LogView = lazy(() => import("./views/log-view"));
const AccessControlList = lazy(() => import("./views/access-control-list")); const AccessControlList = lazy(() => import("./views/access-control-list"));
const RouteListView = lazy(() => import("./views/route/route-list")); const RouteListView = lazy(() => import("./views/route/route-list"));
const RouteEditView = lazy(() => import("./views/route/route-edit")); const RouteEditView = lazy(() => import("./views/route/route-edit"));
const SettingsView = lazy(() => import("./views/settings")); const SettingsView = lazy(() => import("./views/settings/settings"));
const ProfileView = lazy(() => import("./views/profile/profile")); const ProfileView = lazy(() => import("./views/profile/profile"));
export default function AdminDashboard(props) { export default function AdminDashboard(props) {

@ -0,0 +1,7 @@
import {FormGroup, styled} from "@mui/material";
const SpacedFormGroup = styled(FormGroup)((props) => ({
marginBottom: props.theme.spacing(2)
}));
export default SpacedFormGroup;

@ -27,6 +27,7 @@ import MfaTotp from "./mfa-totp";
import MfaFido from "./mfa-fido"; import MfaFido from "./mfa-fido";
import Dialog from "shared/elements/dialog"; import Dialog from "shared/elements/dialog";
import PasswordStrength from "shared/elements/password-strength"; import PasswordStrength from "shared/elements/password-strength";
import SpacedFormGroup from "../../elements/form-group";
const GpgKeyField = styled(TextField)((props) => ({ const GpgKeyField = styled(TextField)((props) => ({
"& > div": { "& > div": {
@ -46,10 +47,6 @@ const GpgFingerprintBox = styled(Box)((props) => ({
} }
})); }));
const ProfileFormGroup = styled(FormGroup)((props) => ({
marginBottom: props.theme.spacing(2)
}));
const MFAOptions = styled(Box)((props) => ({ const MFAOptions = styled(Box)((props) => ({
"& > div": { "& > div": {
borderColor: props.theme.palette.divider, borderColor: props.theme.palette.divider,
@ -231,7 +228,7 @@ export default function ProfileView(props) {
</div> </div>
</div> </div>
<div className={"content"}> <div className={"content"}>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.username")}</FormLabel> <FormLabel>{L("account.username")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
@ -239,8 +236,8 @@ export default function ProfileView(props) {
value={profile.name} value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} /> onChange={e => setProfile({...profile, name: e.target.value })} />
</FormControl> </FormControl>
</ProfileFormGroup> </SpacedFormGroup>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.full_name")}</FormLabel> <FormLabel>{L("account.full_name")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
@ -248,12 +245,12 @@ export default function ProfileView(props) {
value={profile.fullName ?? ""} value={profile.fullName ?? ""}
onChange={e => setProfile({...profile, fullName: e.target.value })} /> onChange={e => setProfile({...profile, fullName: e.target.value })} />
</FormControl> </FormControl>
</ProfileFormGroup> </SpacedFormGroup>
<CollapseBox title={L("account.change_password")} open={openedTab === "password"} <CollapseBox title={L("account.change_password")} open={openedTab === "password"}
onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")} onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
icon={<Password />}> icon={<Password />}>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.password_old")}</FormLabel> <FormLabel>{L("account.password_old")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
@ -263,8 +260,8 @@ export default function ProfileView(props) {
value={changePassword.old} value={changePassword.old}
onChange={e => setChangePassword({...changePassword, old: e.target.value })} /> onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
</FormControl> </FormControl>
</ProfileFormGroup> </SpacedFormGroup>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.password_new")}</FormLabel> <FormLabel>{L("account.password_new")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
@ -273,8 +270,8 @@ export default function ProfileView(props) {
value={changePassword.new} value={changePassword.new}
onChange={e => setChangePassword({...changePassword, new: e.target.value })} /> onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
</FormControl> </FormControl>
</ProfileFormGroup> </SpacedFormGroup>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.password_confirm")}</FormLabel> <FormLabel>{L("account.password_confirm")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
@ -283,7 +280,7 @@ export default function ProfileView(props) {
value={changePassword.confirm} value={changePassword.confirm}
onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} /> onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
</FormControl> </FormControl>
</ProfileFormGroup> </SpacedFormGroup>
<Box className={"w-50"}> <Box className={"w-50"}>
<PasswordStrength password={changePassword.new} minLength={6} /> <PasswordStrength password={changePassword.new} minLength={6} />
</Box> </Box>
@ -303,7 +300,7 @@ export default function ProfileView(props) {
{profile.gpgKey.fingerprint} {profile.gpgKey.fingerprint}
</code> </code>
</GpgFingerprintBox> </GpgFingerprintBox>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.password")}</FormLabel> <FormLabel>{L("account.password")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} size={"small"} <TextField variant={"outlined"} size={"small"}
@ -312,7 +309,7 @@ export default function ProfileView(props) {
placeholder={L("account.password")} placeholder={L("account.password")}
/> />
</FormControl> </FormControl>
</ProfileFormGroup> </SpacedFormGroup>
<Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />} <Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />}
color={"secondary"} onClick={onRemoveGpgKey} color={"secondary"} onClick={onRemoveGpgKey}
variant={"outlined"} size={"small"} variant={"outlined"} size={"small"}
@ -321,7 +318,7 @@ export default function ProfileView(props) {
</Button> </Button>
</Box> : </Box> :
<Box> <Box>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.gpg_key")}</FormLabel> <FormLabel>{L("account.gpg_key")}</FormLabel>
<GpgKeyField value={gpgKey} multiline={true} rows={8} <GpgKeyField value={gpgKey} multiline={true} rows={8}
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")} disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}
@ -334,7 +331,7 @@ export default function ProfileView(props) {
}); });
return false; return false;
}}/> }}/>
</ProfileFormGroup> </SpacedFormGroup>
<ButtonBar> <ButtonBar>
<Button size={"small"} <Button size={"small"}
variant={"outlined"} variant={"outlined"}
@ -372,7 +369,7 @@ export default function ProfileView(props) {
} }
{L("account.2fa_type_" + profile.twoFactorToken.type)} {L("account.2fa_type_" + profile.twoFactorToken.type)}
</GpgFingerprintBox> </GpgFingerprintBox>
<ProfileFormGroup> <SpacedFormGroup>
<FormLabel>{L("account.password")}</FormLabel> <FormLabel>{L("account.password")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} size={"small"} <TextField variant={"outlined"} size={"small"}
@ -381,7 +378,7 @@ export default function ProfileView(props) {
placeholder={L("account.password")} placeholder={L("account.password")}
/> />
</FormControl> </FormControl>
</ProfileFormGroup> </SpacedFormGroup>
<Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />} <Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
color={"secondary"} onClick={onRemove2FA} color={"secondary"} onClick={onRemove2FA}
variant={"outlined"} size={"small"} variant={"outlined"} size={"small"}

@ -0,0 +1,21 @@
import {Checkbox, FormControlLabel} from "@mui/material";
import SpacedFormGroup from "../../elements/form-group";
import {parseBool} from "shared/util";
import {useContext} from "react";
import {LocaleContext} from "shared/locale";
export default function SettingsCheckBox(props) {
const {key_name, value, onChangeValue, disabled, ...other} = props;
const {translate: L} = useContext(LocaleContext);
return <SpacedFormGroup {...other}>
<FormControlLabel
disabled={disabled}
control={<Checkbox
disabled={disabled}
checked={parseBool(value)}
onChange={(e, v) => onChangeValue(v)} />}
label={L("settings." + key_name)} />
</SpacedFormGroup>
}

@ -0,0 +1,22 @@
import {FormControl, FormLabel, TextField} from "@mui/material";
import SpacedFormGroup from "../../elements/form-group";
import {useContext} from "react";
import {LocaleContext} from "shared/locale";
export default function SettingsNumberInput(props) {
const {key_name, value, minValue, maxValue, onChangeValue, disabled, ...other} = props;
const {translate: L} = useContext(LocaleContext);
return <SpacedFormGroup {...other}>
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
type={"number"}
disabled={disabled}
inputProps={{min: minValue, max: maxValue}}
value={value}
onChange={e => onChangeValue(e.target.value)} />
</FormControl>
</SpacedFormGroup>
}

@ -0,0 +1,22 @@
import SpacedFormGroup from "../../elements/form-group";
import {FormControl, FormLabel, TextField} from "@mui/material";
import {useContext} from "react";
import {LocaleContext} from "shared/locale";
export default function SettingsPasswordInput(props) {
const {key_name, value, onChangeValue, disabled, ...other} = props;
const {translate: L} = useContext(LocaleContext);
return <SpacedFormGroup {...other}>
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
type={"password"}
disabled={disabled}
placeholder={"(" + L("general.unchanged") + ")"}
value={value}
onChange={e => onChangeValue(e.target.value)} />
</FormControl>
</SpacedFormGroup>
}

@ -0,0 +1,25 @@
import {FormControl, FormLabel, Select} from "@mui/material";
import SpacedFormGroup from "../../elements/form-group";
import {useContext} from "react";
import {LocaleContext} from "shared/locale";
export default function SettingsSelection(props) {
const {key_name, value, options, onChangeValue, disabled, ...other} = props;
const {translate: L} = useContext(LocaleContext);
return <SpacedFormGroup {...other}>
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<Select native value={value}
disabled={disabled}
size={"small"} onChange={e => onChangeValue(e.target.value)}>
{options.map(option => <option
key={"option-" + option}
value={option}>
{option}
</option>)}
</Select>
</FormControl>
</SpacedFormGroup>
}

@ -0,0 +1,49 @@
import {Autocomplete, Chip, FormLabel, TextField} from "@mui/material";
import SpacedFormGroup from "../../elements/form-group";
import {useCallback, useContext, useState} from "react";
import {LocaleContext} from "shared/locale";
export default function SettingsTextValues(props) {
const {key_name, value, options, onChangeValue, disabled, ...other} = props;
const {translate: L} = useContext(LocaleContext);
const [textInput, setTextInput] = useState("");
const onFinishTyping = useCallback(() => {
setTextInput("");
const newValue = textInput?.trim();
if (newValue) {
onChangeValue(value ? [...value, newValue] : [newValue]);
}
}, [textInput, value]);
return <SpacedFormGroup {...other}>
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<Autocomplete
clearIcon={false}
options={[]}
freeSolo
multiple
value={value || []}
inputValue={textInput}
onChange={(e, v) => onChangeValue(v)}
onInputChange={e => setTextInput(e.target.value.trim())}
renderTags={(values, props) =>
values.map((option, index) => (
<Chip label={option} {...props({ index })} />
))
}
renderInput={(params) => <TextField
{...params}
onKeyDown={e => {
if (["Enter", "Tab", ",", " "].includes(e.key)) {
e.preventDefault();
e.stopPropagation();
onFinishTyping();
}
}}
onBlur={onFinishTyping} />}
/>
</SpacedFormGroup>
}

@ -0,0 +1,20 @@
import SpacedFormGroup from "../../elements/form-group";
import {FormControl, FormLabel, TextField} from "@mui/material";
import {useContext} from "react";
import {LocaleContext} from "shared/locale";
export default function SettingsTextInput(props) {
const {key_name, value, onChangeValue, disabled, ...other} = props;
const {translate: L} = useContext(LocaleContext);
return <SpacedFormGroup {...other}>
<FormLabel disabled={!!disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
disabled={!!disabled}
value={value}
onChange={e => onChangeValue(e.target.value)} />
</FormControl>
</SpacedFormGroup>
}

@ -1,10 +1,10 @@
import {useCallback, useContext, useEffect, useState} from "react"; import {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale"; import {LocaleContext} from "shared/locale";
import { import {
Box, Button, Checkbox, Box, Button,
CircularProgress, FormControl, FormControlLabel, CircularProgress, FormControl,
FormGroup, FormLabel, Grid, IconButton, FormGroup, FormLabel, Grid, IconButton,
Paper, Select, styled, Paper,
Tab, Tab,
Table, Table,
TableBody, TableBody,
@ -13,8 +13,6 @@ import {
TableContainer, TableContainer,
TableRow, TableRow,
Tabs, TextField, Tabs, TextField,
Autocomplete,
Chip
} from "@mui/material"; } from "@mui/material";
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import { import {
@ -29,11 +27,14 @@ import {
SettingsApplications SettingsApplications
} from "@mui/icons-material"; } from "@mui/icons-material";
import TIME_ZONES from "shared/time-zones"; import TIME_ZONES from "shared/time-zones";
import ButtonBar from "../elements/button-bar"; import ButtonBar from "../../elements/button-bar";
import {parseBool} from "shared/util";
const SettingsFormGroup = styled(FormGroup)((props) => ({ import SettingsTextValues from "./input-text-values";
marginBottom: props.theme.spacing(1), import SettingsCheckBox from "./input-check-box";
})); import SettingsNumberInput from "./input-number";
import SettingsPasswordInput from "./input-password";
import SettingsTextInput from "./input-text";
import SettingsSelection from "./input-selection";
export default function SettingsView(props) { export default function SettingsView(props) {
@ -71,7 +72,6 @@ export default function SettingsView(props) {
// data // data
const [fetchSettings, setFetchSettings] = useState(true); const [fetchSettings, setFetchSettings] = useState(true);
const [settings, setSettings] = useState(null); const [settings, setSettings] = useState(null);
const [extra, setExtra] = useState({});
const [uncategorizedKeys, setUncategorizedKeys] = useState([]); const [uncategorizedKeys, setUncategorizedKeys] = useState([]);
// ui // ui
@ -197,118 +197,41 @@ export default function SettingsView(props) {
setFetchSettings(true); setFetchSettings(true);
setNewKey(""); setNewKey("");
setChanged(false); setChanged(false);
setExtra({});
}, []); }, []);
const parseBool = (v) => v !== undefined && (v === true || v === 1 || ["true", "1", "yes"].includes(v.toString().toLowerCase())); const getInputProps = (key_name, disabled = false, props = {}) => {
return {
key: "form-" + key_name,
key_name: key_name,
value: settings[key_name],
disabled: disabled,
onChangeValue: v => setSettings({...settings, [key_name]: v}),
...props
};
}
const renderTextInput = (key_name, disabled=false, props={}) => { const renderTextInput = (key_name, disabled=false, props={}) => {
return <SettingsFormGroup key={"form-" + key_name} {...props}> return <SettingsTextInput {...getInputProps(key_name, disabled, props)} />
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
disabled={disabled}
value={settings[key_name]}
onChange={e => onChangeValue(key_name, e.target.value)} />
</FormControl>
</SettingsFormGroup>
} }
const renderPasswordInput = (key_name, disabled=false, props={}) => { const renderPasswordInput = (key_name, disabled=false, props={}) => {
return <SettingsFormGroup key={"form-" + key_name} {...props}> return <SettingsPasswordInput {...getInputProps(key_name, disabled, props)} />
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
type={"password"}
disabled={disabled}
placeholder={"(" + L("general.unchanged") + ")"}
value={settings[key_name]}
onChange={e => onChangeValue(key_name, e.target.value)} />
</FormControl>
</SettingsFormGroup>
} }
const renderNumberInput = (key_name, minValue, maxValue, disabled=false, props={}) => { const renderNumberInput = (key_name, minValue, maxValue, disabled=false, props={}) => {
return <SettingsFormGroup key={"form-" + key_name} {...props}> return <SettingsNumberInput minValue={minValue} maxValue={maxValue} {...getInputProps(key_name, disabled, props)} />
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
type={"number"}
disabled={disabled}
inputProps={{min: minValue, max: maxValue}}
value={settings[key_name]}
onChange={e => onChangeValue(key_name, e.target.value)} />
</FormControl>
</SettingsFormGroup>
} }
const renderCheckBox = (key_name, disabled=false, props={}) => { const renderCheckBox = (key_name, disabled=false, props={}) => {
return <SettingsFormGroup key={"form-" + key_name} {...props}> return <SettingsCheckBox {...getInputProps(key_name, disabled, props)} />
<FormControlLabel
disabled={disabled}
control={<Checkbox
disabled={disabled}
checked={parseBool(settings[key_name])}
onChange={(e, v) => onChangeValue(key_name, v)} />}
label={L("settings." + key_name)} />
</SettingsFormGroup>
} }
const renderSelection = (key_name, options, disabled=false, props={}) => { const renderSelection = (key_name, options, disabled=false, props={}) => {
return <SettingsFormGroup key={"form-" + key_name} {...props}> return <SettingsSelection options={options} {...getInputProps(key_name, disabled, props)} />
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<FormControl>
<Select native value={settings[key_name]}
disabled={disabled}
size={"small"} onChange={e => onChangeValue(key_name, e.target.value)}>
{options.map(option => <option
key={"option-" + option}
value={option}>
{option}
</option>)}
</Select>
</FormControl>
</SettingsFormGroup>
} }
const renderTextValuesInput = (key_name, disabled=false, props={}) => { const renderTextValuesInput = (key_name, disabled=false, props={}) => {
return <SettingsTextValues {...getInputProps(key_name, disabled, props)} />
const finishTyping = () => {
console.log("finishTyping", key_name);
setExtra({...extra, [key_name]: ""});
if (extra[key_name]) {
setSettings({...settings, [key_name]: [...settings[key_name], extra[key_name]]});
}
}
return <SettingsFormGroup key={"form-" + key_name} {...props}>
<FormLabel disabled={disabled}>{L("settings." + key_name)}</FormLabel>
<Autocomplete
clearIcon={false}
options={[]}
freeSolo
multiple
value={settings[key_name]}
onChange={(e, v) => setSettings({...settings, [key_name]: v})}
renderTags={(values, props) =>
values.map((option, index) => (
<Chip label={option} {...props({ index })} />
))
}
renderInput={(params) => <TextField
{...params}
value={extra[key_name] ?? ""}
onChange={e => setExtra({...extra, [key_name]: e.target.value.trim()})}
onKeyDown={e => {
if (["Enter", "Tab", " "].includes(e.key)) {
e.preventDefault();
e.stopPropagation();
finishTyping();
}
}}
onBlur={finishTyping} />}
/>
</SettingsFormGroup>
} }
const renderTab = () => { const renderTab = () => {

@ -109,7 +109,12 @@ const isInt = (value) => {
!isNaN(parseInt(value, 10)); !isNaN(parseInt(value, 10));
} }
const parseBool = (v) => v !== undefined &&
(v === true || v === 1 || ["true", "1", "yes"].includes(v.toString().toLowerCase()));
export { humanReadableSize, removeParameter, getParameter, getCookie, export { humanReadableSize, removeParameter, getParameter, getCookie,
encodeText, decodeText, getBaseUrl, encodeText, decodeText, getBaseUrl,
formatDate, formatDateTime, formatDistance, formatDate, formatDateTime, formatDistance,
upperFirstChars, isInt, createDownload }; upperFirstChars, isInt, parseBool, createDownload };