2.4.1: Settings GPG, Localization, CLI DB migrate, minor improvements

This commit is contained in:
2024-05-11 16:12:15 +02:00
parent 7920d3164d
commit 150e4eb195
28 changed files with 636 additions and 241 deletions

View File

@@ -0,0 +1,15 @@
import {styled} from "@mui/material";
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
export default VisuallyHiddenInput;

View File

@@ -39,6 +39,12 @@ const StyledStatBox = styled(Alert)((props) => ({
},
"& div:nth-of-type(1)": {
padding: props.theme.spacing(2),
"& span": {
fontSize: "2.5em",
},
"& p": {
fontSize: "1em",
}
},
"& div:nth-of-type(2) > svg": {
position: "absolute",
@@ -49,8 +55,18 @@ const StyledStatBox = styled(Alert)((props) => ({
},
"& div:nth-of-type(3)": {
backdropFilter: "brightness(70%)",
textAlign: "right",
padding: props.theme.spacing(0.5),
"& a": {
display: "grid",
gridTemplateColumns: "auto 30px",
alignItems: "center",
justifyContent: "end",
textDecoration: "none",
"& svg": {
textAlign: "center",
justifySelf: "center"
}
}
}
},
}));
@@ -60,7 +76,7 @@ const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false}
<Box>
{!isNaN(props.count) ?
<>
<h2>{props.count}</h2>
<span>{props.count}</span>
<p>{props.text}</p>
</> : <CircularProgress variant={"determinate"} />
}
@@ -68,7 +84,8 @@ const StatBox = (props) => <StyledStatBox variant={"filled"} icon={false}
<Box>{props.icon}</Box>
<Box>
<Link to={props.link}>
More info <ArrowCircleRight />
<span>{props.L("admin.more_info")}</span>
<ArrowCircleRight />
</Link>
</Box>
</StyledStatBox>
@@ -131,25 +148,25 @@ export default function Overview(props) {
<StatBox color={"info"} count={stats?.userCount}
text={L("admin.users_registered")}
icon={<People/>}
link={"/admin/users"}/>
link={"/admin/users"} L={L}/>
</Grid>
<Grid item xs={6} lg={3}>
<StatBox color={"success"} count={stats?.groupCount}
text={L("admin.available_groups")}
icon={<Groups/>}
link={"/admin/groups"}/>
link={"/admin/groups"} L={L}/>
</Grid>
<Grid item xs={6} lg={3}>
<StatBox color={"warning"} count={stats?.pageCount}
text={L("admin.routes_defined")}
icon={<LibraryBooks/>}
link={"/admin/routes"}/>
link={"/admin/routes"} L={L}/>
</Grid>
<Grid item xs={6} lg={3}>
<StatBox color={"error"} count={stats?.errorCount}
text={L("admin.error_count")}
icon={<BugReport />}
link={"/admin/logs"}/>
link={"/admin/logs"} L={L}/>
</Grid>
</Grid>
<Box m={2} p={2} component={Paper}>

View File

@@ -5,6 +5,7 @@ import {CheckCircle, CloudUpload, ErrorOutline, Remove, Upload, VpnKey} from "@m
import SpacedFormGroup from "../../elements/form-group";
import ButtonBar from "../../elements/button-bar";
import CollapseBox from "./collapse-box";
import VisuallyHiddenInput from "../../elements/hidden-file-upload";
const GpgKeyField = styled(TextField)((props) => ({
"& > div": {
@@ -24,18 +25,6 @@ const GpgFingerprintBox = styled(Box)((props) => ({
}
}));
const VisuallyHiddenInput = styled('input')({
clip: 'rect(0 0 0 0)',
clipPath: 'inset(50%)',
height: 1,
overflow: 'hidden',
position: 'absolute',
bottom: 0,
left: 0,
whiteSpace: 'nowrap',
width: 1,
});
export default function GpgBox(props) {
// meta
@@ -87,7 +76,7 @@ export default function GpgBox(props) {
data += event.target.result;
if (reader.readyState === 2) {
if (!data.match(/^-+\s*BEGIN/m)) {
showDialog(L("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file"));
showDialog(L("account.invalid_gpg_key"), L("account.error_reading_file"));
return false;
} else {
callback(data);
@@ -98,9 +87,7 @@ export default function GpgBox(props) {
reader.readAsText(file);
}, [showDialog]);
return <CollapseBox title={L("account.gpg_key")} {...other}
icon={<VpnKey />}>
return <CollapseBox title={L("account.gpg_key")} icon={<VpnKey />} {...other}>
{
profile.gpgKey ? <Box>
<GpgFingerprintBox mb={2}>
@@ -150,8 +137,8 @@ export default function GpgBox(props) {
variant="outlined"
startIcon={<CloudUpload />}
component={"label"}>
Upload file
<VisuallyHiddenInput type={"file"} onChange={e => {
{L("general.upload_file")}
<VisuallyHiddenInput type={"file"} onChange={e => {
let file = e.target.files[0];
getFileContents(file, (data) => {
setGpgKey(data);

View File

@@ -0,0 +1,98 @@
import {Box, IconButton, styled, TextField} from "@mui/material";
import {Delete, Upload} from "@mui/icons-material";
import React, {useCallback, useContext, useRef, useState} from "react";
import {LocaleContext} from "shared/locale";
import VisuallyHiddenInput from "../../elements/hidden-file-upload";
const StyledGpgKeyInput = styled(Box)((props) => ({
display: "grid",
gridTemplateColumns: "40px auto",
"& button": {
padding: 0,
borderWidth: 1,
borderStyle: "solid",
borderColor: props.theme.palette.grey[400],
borderTopLeftRadius: 5,
borderBottomLeftRadius: 5,
borderTopRightRadius: 0,
borderBottomRightRadius: 0,
backgroundColor: props.theme.palette.grey[300],
},
"& > div > div": {
borderTopLeftRadius: 0,
borderBottomLeftRadius: 0,
}
}));
export default function GpgKeyInput(props) {
const { value, api, showDialog, onChange, ...other } = props;
const {translate: L} = useContext(LocaleContext);
const isConfigured = !!value;
const fileInputRef = useRef(null);
const onRemoveKey = useCallback(() => {
api.settingsRemoveGPG().then(data => {
if (!data.success) {
showDialog(data.msg, L("settings.remove_gpg_key_error"));
} else {
onChange(null);
}
});
}, [api, showDialog, onChange]);
const onImportGPG = useCallback((publicKey) => {
api.settingsImportGPG(publicKey).then(data => {
if (!data.success) {
showDialog(data.msg, L("settings.import_gpg_key_error"));
} else {
onChange(data.gpgKey);
}
});
}, [api, showDialog, onChange]);
const onOpenDialog = useCallback(() => {
if (isConfigured) {
showDialog(
L("settings.remove_gpg_key_text"),
L("settings.remove_gpg_key"),
[L("general.cancel"), L("general.remove")],
button => button === 1 ? onRemoveKey() : true
);
} else if (fileInputRef?.current) {
fileInputRef.current.click();
}
}, [showDialog, isConfigured, onRemoveKey, fileInputRef?.current]);
const getFileContents = useCallback((file, callback) => {
let reader = new FileReader();
let data = "";
reader.onload = function(event) {
data += event.target.result;
if (reader.readyState === 2) {
if (!data.match(/^-+\s*BEGIN/m)) {
showDialog(L("account.invalid_gpg_key"), L("account.error_reading_file"));
return false;
} else {
callback(data);
}
}
};
reader.readAsText(file);
}, [showDialog]);
return <StyledGpgKeyInput {...other}>
<IconButton onClick={onOpenDialog}>
{ isConfigured ? <Delete color={"error"} /> : <Upload color={"success"} /> }
</IconButton>
<VisuallyHiddenInput ref={fileInputRef} type={"file"} onChange={e => {
let file = e.target.files[0];
getFileContents(file, (data) => {
onImportGPG(data);
});
return false;
}} />
<TextField variant={"outlined"} size={"small"} disabled={true}
value={value?.fingerprint ?? L("settings.no_gpg_key_configured")} />
</StyledGpgKeyInput>
}

View File

@@ -3,7 +3,7 @@ import {LocaleContext} from "shared/locale";
import {
Box, Button,
CircularProgress, FormControl,
FormGroup, FormLabel, Grid, IconButton,
FormLabel, Grid, IconButton,
Paper,
Tab,
Table,
@@ -23,7 +23,7 @@ import {
RestartAlt,
Save,
Send,
SettingsApplications, SmartToy, Storage
SettingsApplications, SmartToy, Storage,
} from "@mui/icons-material";
import TIME_ZONES from "shared/time-zones";
import ButtonBar from "../../elements/button-bar";
@@ -34,10 +34,12 @@ import SettingsPasswordInput from "./input-password";
import SettingsTextInput from "./input-text";
import SettingsSelection from "./input-selection";
import ViewContent from "../../elements/view-content";
import GpgKeyInput from "./input-gpg-key";
import SpacedFormGroup from "../../elements/form-group";
export default function SettingsView(props) {
// TODO: website-logo (?), mail_contact, mail_contact_gpg_key_id
// TODO: website-logo (?), mail_contact_gpg_key_id
// meta
const api = props.api;
@@ -47,6 +49,7 @@ export default function SettingsView(props) {
"general": [
"base_url",
"site_name",
"mail_contact",
"user_registration_enabled",
"time_zone",
"allowed_extensions",
@@ -75,6 +78,8 @@ export default function SettingsView(props) {
]
};
const CUSTOM_KEYS = ["mail_contact_gpg_key"];
// data
const [fetchSettings, setFetchSettings] = useState(true);
const [settings, setSettings] = useState(null);
@@ -94,8 +99,12 @@ export default function SettingsView(props) {
}, [])).includes(key);
}
const isCustom = (key) => {
return CUSTOM_KEYS.includes(key);
}
useEffect(() => {
requestModules(props.api, ["general", "settings"], currentLocale).then(data => {
requestModules(props.api, ["general", "settings", "account"], currentLocale).then(data => {
if (!data.success) {
showDialog("Error fetching translations: " + data.msg);
}
@@ -115,7 +124,9 @@ export default function SettingsView(props) {
return obj;
}, {})
);
setUncategorizedKeys(Object.keys(data.settings).filter(key => isUncategorized(key)));
setUncategorizedKeys(Object.keys(data.settings)
.filter(key => !isCustom(key))
.filter(key => isUncategorized(key)));
}
});
}
@@ -132,7 +143,15 @@ export default function SettingsView(props) {
const onSaveSettings = useCallback(() => {
setSaving(true);
api.saveSettings(settings).then(data => {
let settingsToSave = {...settings};
for (const key of CUSTOM_KEYS) {
if (settingsToSave.hasOwnProperty(key)) {
delete settingsToSave[key];
}
}
api.saveSettings(settingsToSave).then(data => {
setSaving(false);
if (data.success) {
showDialog(L("settings.save_settings_success"), L("general.success"));
@@ -253,6 +272,13 @@ export default function SettingsView(props) {
if (selectedTab === "general") {
return [
renderTextInput("site_name"),
renderTextInput("mail_contact", false, {type: "email"}),
<SpacedFormGroup key={"mail-contact-gpg-key"}>
<FormLabel>{L("settings.mail_contact_gpg_key")}</FormLabel>
<GpgKeyInput value={settings.mail_contact_gpg_key} api={api}
showDialog={showDialog}
onChange={value => setSettings({...settings, mail_contact_gpg_key: value})}/>
</SpacedFormGroup>,
renderTextInput("base_url"),
renderTextValuesInput("trusted_domains"),
renderCheckBox("user_registration_enabled"),
@@ -269,7 +295,7 @@ export default function SettingsView(props) {
renderPasswordInput("mail_password", !settings.mail_enabled),
renderTextInput("mail_footer", !settings.mail_enabled),
renderCheckBox("mail_async", !settings.mail_enabled),
<FormGroup key={"mail-test"}>
<SpacedFormGroup key={"mail-test"}>
<FormLabel>{L("settings.send_test_email")}</FormLabel>
<FormControl disabled={!settings.mail_enabled}>
<Grid container spacing={1}>
@@ -292,7 +318,7 @@ export default function SettingsView(props) {
</Grid>
</Grid>
</FormControl>
</FormGroup>
</SpacedFormGroup>
];
} else if (selectedTab === "captcha") {
let captchaOptions = {};

View File

@@ -16,7 +16,7 @@ import * as React from "react";
import ViewContent from "../../elements/view-content";
import FormGroup from "../../elements/form-group";
import ButtonBar from "../../elements/button-bar";
import {RestartAlt, Save, Send} from "@mui/icons-material";
import {Delete, RestartAlt, Save, Send} from "@mui/icons-material";
import PasswordStrength from "shared/elements/password-strength";
const initialUser = {
@@ -51,20 +51,20 @@ export default function UserEditView(props) {
const [sendInvite, setSetInvite] = useState(isNewUser);
useEffect(() => {
requestModules(props.api, ["general", "account"], currentLocale).then(data => {
requestModules(api, ["general", "account"], currentLocale).then(data => {
if (!data.success) {
props.showDialog("Error fetching translations: " + data.msg);
showDialog("Error fetching translations: " + data.msg);
}
});
}, [currentLocale]);
const onFetchGroups = useCallback(() => {
api.searchGroups(groupInput, user?.groups?.map(group => group.id)).then((res) => {
if (res.success) {
setGroups(res.groups);
} else {
showDialog(res.msg, L("account.search_groups_error"));
}
if (res.success) {
setGroups(res.groups);
} else {
showDialog(res.msg, L("account.search_groups_error"));
}
});
}, [api, showDialog, user?.groups, groupInput]);
@@ -110,7 +110,7 @@ export default function UserEditView(props) {
});
} else {
api.createUser(user.name, user.fullName, user.email, groupIds,
user.password, user.passwordConfirm
user.password, user.passwordConfirm
).then(res => {
setSaving(false);
if (res.success) {
@@ -143,6 +143,16 @@ export default function UserEditView(props) {
setChanged(true);
}, [user]);
const onDeleteUser = useCallback(() => {
api.deleteUser(userId).then(res => {
if (res.success) {
navigate("/admin/users");
} else {
showDialog(res.msg, L("account.delete_user_error"));
}
});
}, [api, showDialog, userId]);
useEffect(() => {
if (!isNewUser) {
onFetchUser(true);
@@ -163,118 +173,132 @@ export default function UserEditView(props) {
<span key={"action"}>{isNewUser ? L("general.new") : L("general.edit")}</span>
]}>
<Grid container>
<Grid item xs={12} mt={1} mb={1}>
<Button variant={"outlined"} color={"error"} size={"small"}
startIcon={<Delete />}
disabled={isNewUser || !api.hasPermission("user/delete") || user.id === api.user.id}
onClick={() => showDialog(
L("account.delete_user_text"),
L("account.delete_user_title"),
[L("general.cancel"), L("general.confirm")],
(buttonIndex) => buttonIndex === 1 ? onDeleteUser() : true)
}
>
{L("general.delete")}
</Button>
</Grid>
<Grid item xs={12} lg={6}>
<FormGroup>
<FormLabel>{L("account.name")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.name}
onChange={e => onChangeValue("name", e.target.value)} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.full_name")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.fullName}
onChange={e => onChangeValue("fullName", e.target.value)} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.email")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.email ?? ""}
type={"email"}
onChange={e => onChangeValue("email", e.target.value)} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.groups")}</FormLabel>
<Autocomplete
options={Object.values(groups || {})}
getOptionLabel={group => group.name}
getOptionKey={group => group.id}
filterOptions={(options) => options}
clearOnBlur={true}
clearOnEscape
freeSolo
multiple
value={user.groups}
inputValue={groupInput}
onChange={(e, v) => onChangeValue("groups", v)}
onInputChange={e => setGroupInput((!e || e.target.value === 0) ? "" : e.target.value) }
renderTags={(values, props) =>
values.map((option, index) => {
return <Chip label={option.name}
style={{backgroundColor: option.color}}
{...props({index})} />
})
}
renderInput={(params) => <TextField {...params}
onBlur={() => setGroupInput("")} />}
/>
</FormGroup>
{ !isNewUser ?
<>
<FormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.password}
type={"password"}
placeholder={"(" + L("general.unchanged") + ")"}
onChange={e => onChangeValue("password", e.target.value)} />
</FormControl>
</FormGroup>
<MuiFormGroup>
<FormControlLabel
control={<Checkbox
checked={!!user.active}
onChange={(e, v) => onChangeValue("active", v)} />}
label={L("account.active")} />
</MuiFormGroup>
<FormGroup>
<FormControlLabel
control={<Checkbox
checked={!!user.confirmed}
onChange={(e, v) => onChangeValue("confirmed", v)} />}
label={L("account.confirmed")} />
</FormGroup>
</> : <>
<FormGroup>
<FormControlLabel
control={<Checkbox
checked={sendInvite}
onChange={(e, v) => setSetInvite(v)} />}
label={L("account.send_invite")} />
</FormGroup>
{!sendInvite && <>
<FormGroup>
<FormLabel>{L("account.name")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.name}
onChange={e => onChangeValue("name", e.target.value)} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.full_name")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.fullName}
onChange={e => onChangeValue("fullName", e.target.value)} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.email")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.email ?? ""}
type={"email"}
onChange={e => onChangeValue("email", e.target.value)} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.groups")}</FormLabel>
<Autocomplete
options={Object.values(groups || {})}
getOptionLabel={group => group.name}
getOptionKey={group => group.id}
filterOptions={(options) => options}
clearOnBlur={true}
clearOnEscape
freeSolo
multiple
value={user.groups}
inputValue={groupInput}
onChange={(e, v) => onChangeValue("groups", v)}
onInputChange={e => setGroupInput((!e || e.target.value === 0) ? "" : e.target.value) }
renderTags={(values, props) =>
values.map((option, index) => {
return <Chip label={option.name}
style={{backgroundColor: option.color}}
{...props({index})} />
})
}
renderInput={(params) => <TextField {...params}
onBlur={() => setGroupInput("")} />}
/>
</FormGroup>
{ !isNewUser ?
<>
<FormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.password}
type={"password"}
placeholder={"(" + L("general.unchanged") + ")"}
onChange={e => onChangeValue("password", e.target.value)} />
</FormControl>
</FormGroup>
<MuiFormGroup>
<FormControlLabel
control={<Checkbox
checked={!!user.active}
onChange={(e, v) => onChangeValue("active", v)} />}
label={L("account.active")} />
</MuiFormGroup>
<FormGroup>
<FormLabel>{L("account.password_confirm")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.passwordConfirm}
type={"password"}
onChange={e => onChangeValue("passwordConfirm", e.target.value)} />
</FormControl>
<FormControlLabel
control={<Checkbox
checked={!!user.confirmed}
onChange={(e, v) => onChangeValue("confirmed", v)} />}
label={L("account.confirmed")} />
</FormGroup>
<Box mb={2}>
<PasswordStrength password={user.password} />
</Box>
</> : <>
<FormGroup>
<FormControlLabel
control={<Checkbox
checked={sendInvite}
onChange={(e, v) => setSetInvite(v)} />}
label={L("account.send_invite")} />
</FormGroup>
{!sendInvite && <>
<FormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.password}
type={"password"}
onChange={e => onChangeValue("password", e.target.value)} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.password_confirm")}</FormLabel>
<FormControl>
<TextField size={"small"} variant={"outlined"}
value={user.passwordConfirm}
type={"password"}
onChange={e => onChangeValue("passwordConfirm", e.target.value)} />
</FormControl>
</FormGroup>
<Box mb={2}>
<PasswordStrength password={user.password} />
</Box>
</>
}
</>
}
</>
}
}
</Grid>
</Grid>
<ButtonBar>