Mail bugfix, gpg, profile frontend WIP

This commit is contained in:
2024-04-06 19:09:12 +02:00
parent fe81e0f6fa
commit e97ac34365
17 changed files with 377 additions and 102 deletions

View File

@@ -0,0 +1,9 @@
import {Box, styled} from "@mui/material";
const ButtonBar = styled(Box)((props) => ({
"& > button, & > label": {
marginRight: props.theme.spacing(1)
}
}));
export default ButtonBar;

View File

@@ -7,9 +7,10 @@ import {ControlsColumn, DataTable, NumericColumn, StringColumn} from "shared/ele
import EditIcon from "@mui/icons-material/Edit";
import usePagination from "shared/hooks/pagination";
import Dialog from "shared/elements/dialog";
import {Box, FormControl, FormGroup, FormLabel, styled, TextField, Button, CircularProgress} from "@mui/material";
import {FormControl, FormGroup, FormLabel, styled, TextField, Button, CircularProgress} from "@mui/material";
import {Add, Delete, KeyboardArrowLeft, Save} from "@mui/icons-material";
import {MuiColorInput} from "mui-color-input";
import ButtonBar from "../../elements/button-bar";
const defaultGroupData = {
name: "",
@@ -17,12 +18,6 @@ const defaultGroupData = {
members: []
};
const ButtonBar = styled(Box)((props) => ({
"& > button": {
marginRight: props.theme.spacing(1)
}
}));
export default function EditGroupView(props) {
const {translate: L, requestModules, currentLocale} = useContext(LocaleContext);

View File

@@ -0,0 +1,39 @@
import {Box, Collapse, FormControl, FormGroup, FormLabel, Paper, styled, TextField} from "@mui/material";
import {ExpandLess, ExpandMore} from "@mui/icons-material";
const StyledBox = styled(Box)((props) => ({
"& > header": {
display: "grid",
gridTemplateColumns: "50px 50px auto",
cursor: "pointer",
marginTop: props.theme.spacing(1),
padding: props.theme.spacing(1),
"& > svg": {
justifySelf: "center",
},
"& > h5": {
margin: 0
}
},
"& > div:nth-of-type(1)": {
padding: props.theme.spacing(2),
borderTopWidth: 1,
borderTopColor: props.theme.palette.divider,
borderTopStyle: "solid"
}
}));
export default function CollapseBox(props) {
const {open, title, icon, children, onToggle, ...other} = props;
return <StyledBox component={Paper} {...other}>
<header onClick={onToggle}>
{ open ? <ExpandLess/> : <ExpandMore /> }
{ icon }
<h5>{title}</h5>
</header>
<Collapse in={open} timeout={"auto"} unmountOnExit>
{children}
</Collapse>
</StyledBox>
}

View File

@@ -1,8 +1,62 @@
import {Link} from "react-router-dom";
import React, {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale";
import {Button, CircularProgress, FormControl, FormGroup, FormLabel, TextField} from "@mui/material";
import {Save} from "@mui/icons-material";
import {
Box,
Button,
CircularProgress,
FormControl,
FormGroup,
FormLabel, styled,
TextField
} from "@mui/material";
import {
CheckCircle,
CloudUpload,
ErrorOutline,
Fingerprint,
Password,
Remove,
Save,
Upload,
VpnKey
} from "@mui/icons-material";
import CollapseBox from "./collapse-box";
import ButtonBar from "../../elements/button-bar";
const GpgKeyField = styled(TextField)((props) => ({
"& > div": {
fontFamily: "monospace",
padding: props.theme.spacing(1),
fontSize: '0.8rem',
},
marginBottom: props.theme.spacing(1)
}));
const GpgFingerprintBox = styled(Box)((props) => ({
"& > svg": {
marginRight: props.theme.spacing(1),
},
"& > code": {
cursor: "pointer"
}
}));
const ProfileFormGroup = styled(FormGroup)((props) => ({
marginBottom: props.theme.spacing(2)
}));
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 ProfileView(props) {
@@ -22,9 +76,14 @@ export default function ProfileView(props) {
// data
const [profile, setProfile] = useState({...api.user});
const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
const [gpgKey, setGpgKey] = useState("");
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
// ui
const [openedTab, setOpenedTab] = useState(null);
const [isSaving, setSaving] = useState(false);
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
const onUpdateProfile = useCallback(() => {
@@ -60,6 +119,54 @@ export default function ProfileView(props) {
}, [profile, changePassword, api, showDialog, isSaving]);
const onUploadGPG = useCallback(() => {
if (!isGpgKeyUploading) {
setGpgKeyUploading(true);
api.uploadGPG(gpgKey).then(data => {
setGpgKeyUploading(false);
if (!data.success) {
showDialog(data.msg, L("account.upload_gpg_error"));
} else {
setProfile({...profile, gpgKey: data.gpgKey});
setGpgKey("");
}
});
}
}, [api, showDialog, isGpgKeyUploading, profile, gpgKey]);
const onRemoveGpgKey = useCallback(() => {
if (!isGpgKeyRemoving) {
setGpgKeyRemoving(true);
api.removeGPG(gpgKeyPassword).then(data => {
setGpgKeyRemoving(false);
setGpgKeyPassword("");
if (!data.success) {
showDialog(data.msg, L("account.remove_gpg_error"));
} else {
setProfile({...profile, gpgKey: null});
}
});
}
}, [api, showDialog, isGpgKeyRemoving, gpgKeyPassword]);
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("Selected file is a not a GPG Public Key in ASCII format"), L("Error reading file"));
return false;
} else {
callback(data);
}
}
};
setGpgKey("");
reader.readAsText(file);
}, [showDialog]);
return <>
<div className={"content-header"}>
<div className={"container-fluid"}>
@@ -79,7 +186,7 @@ export default function ProfileView(props) {
</div>
</div>
<div className={"content"}>
<FormGroup>
<ProfileFormGroup>
<FormLabel>{L("account.username")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
@@ -87,8 +194,8 @@ export default function ProfileView(props) {
value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} />
</FormControl>
</FormGroup>
<FormGroup>
</ProfileFormGroup>
<ProfileFormGroup>
<FormLabel>{L("account.full_name")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
@@ -96,46 +203,129 @@ export default function ProfileView(props) {
value={profile.fullName ?? ""}
onChange={e => setProfile({...profile, fullName: e.target.value })} />
</FormControl>
</FormGroup>
<h4>{L("account.change_password")}</h4>
<FormGroup>
<FormLabel>{L("account.old_password")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
type={"password"}
placeholder={L("general.unchanged")}
value={changePassword.old}
onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.new_password")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
type={"password"}
value={changePassword.new}
onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
</FormControl>
</FormGroup>
<FormGroup>
<FormLabel>{L("account.confirm_password")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
type={"password"}
placeholder={L("general.unchanged")}
value={changePassword.confirm}
onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
</FormControl>
</FormGroup>
<Button variant={"outlined"} color={"primary"}
disabled={isSaving || !api.hasPermission("user/updateProfile")}
startIcon={isSaving ? <CircularProgress size={12} /> : <Save />}
onClick={onUpdateProfile}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
</ProfileFormGroup>
<CollapseBox title={L("account.change_password")} open={openedTab === "password"}
onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
icon={<Password />}>
<ProfileFormGroup>
<FormLabel>{L("account.password_old")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
type={"password"}
placeholder={L("general.unchanged")}
value={changePassword.old}
onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
</FormControl>
</ProfileFormGroup>
<ProfileFormGroup>
<FormLabel>{L("account.password_new")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
type={"password"}
value={changePassword.new}
onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
</FormControl>
</ProfileFormGroup>
<ProfileFormGroup>
<FormLabel>{L("account.password_confirm")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
type={"password"}
value={changePassword.confirm}
onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
</FormControl>
</ProfileFormGroup>
</CollapseBox>
<CollapseBox title={L("account.gpg_key")} open={openedTab === "gpg"}
onToggle={() => setOpenedTab(openedTab === "gpg" ? "" : "gpg")}
icon={<VpnKey />}>
{
profile.gpgKey ? <Box>
<GpgFingerprintBox mb={2}>
{ profile.gpgKey.confirmed ?
<CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
<ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")} />
}
GPG-Fingerprint: <code title={L("general.click_to_copy")} onClick={() => navigator.clipboard.writeText(profile.gpgKey.fingerprint)}>
{profile.gpgKey.fingerprint}
</code>
</GpgFingerprintBox>
<ProfileFormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField variant={"outlined"} size={"small"}
value={gpgKeyPassword} type={"password"}
onChange={e => setGpgKeyPassword(e.target.value)}
placeholder={L("account.password")}
/>
</FormControl>
</ProfileFormGroup>
<Button startIcon={isGpgKeyRemoving ? <CircularProgress size={12} /> : <Remove />}
color={"secondary"} onClick={onRemoveGpgKey}
variant={"outlined"} size={"small"}
disabled={isGpgKeyRemoving || !api.hasPermission("user/removeGPG")}>
{isGpgKeyRemoving ? L("general.removing") + "…" : L("general.remove")}
</Button>
</Box> :
<Box>
<ProfileFormGroup>
<FormLabel>{L("account.gpg_key")}</FormLabel>
<GpgKeyField value={gpgKey} multiline={true} rows={8}
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}
placeholder={L("account.gpg_key_placeholder_text")}
onChange={e => setGpgKey(e.target.value)}
onDrop={e => {
let file = e.dataTransfer.files[0];
getFileContents(file, (data) => {
setGpgKey(data);
});
return false;
}}/>
</ProfileFormGroup>
<ButtonBar>
<Button size={"small"}
variant={"outlined"}
startIcon={<CloudUpload />}
component={"label"}>
Upload file
<VisuallyHiddenInput type={"file"} onChange={e => {
let file = e.target.files[0];
getFileContents(file, (data) => {
setGpgKey(data);
});
return false;
}} />
</Button>
<Button startIcon={isGpgKeyUploading ? <CircularProgress size={12} /> : <Upload />}
color={"primary"} onClick={onUploadGPG}
variant={"outlined"} size={"small"}
disabled={isGpgKeyUploading || !api.hasPermission("user/importGPG")}>
{isGpgKeyUploading ? L("general.uploading") + "…" : L("general.upload")}
</Button>
</ButtonBar>
</Box>
}
</CollapseBox>
<CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
icon={<Fingerprint />}>
<b>test</b>
</CollapseBox>
<Box mt={2}>
<Button variant={"outlined"} color={"primary"}
disabled={isSaving || !api.hasPermission("user/updateProfile")}
startIcon={isSaving ? <CircularProgress size={12} /> : <Save />}
onClick={onUpdateProfile}>
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
</Box>
</div>
</>
}

View File

@@ -10,12 +10,7 @@ import {
import * as React from "react";
import RouteForm from "./route-form";
import {KeyboardArrowLeft, Save} from "@mui/icons-material";
const ButtonBar = styled(Box)((props) => ({
"& > button": {
marginRight: props.theme.spacing(1)
}
}));
import ButtonBar from "../../elements/button-bar";
const MonoSpaceTextField = styled(TextField)((props) => ({
"& input": {

View File

@@ -27,17 +27,12 @@ import {
SettingsApplications
} from "@mui/icons-material";
import TIME_ZONES from "shared/time-zones";
import ButtonBar from "../elements/button-bar";
const SettingsFormGroup = styled(FormGroup)((props) => ({
marginBottom: props.theme.spacing(1),
}));
const ButtonBar = styled(Box)((props) => ({
"& > button": {
marginRight: props.theme.spacing(1)
}
}));
export default function SettingsView(props) {
// meta
@@ -180,7 +175,12 @@ export default function SettingsView(props) {
api.sendTestMail(testMailAddress).then(data => {
setSending(false);
if (!data.success) {
showDialog(data.msg, L("settings.send_test_email_error"));
showDialog(<>
{data.msg} <br />
<code>
{data.output}
</code>
</>, L("settings.send_test_email_error"));
} else {
showDialog(L("settings.send_test_email_success"), L("general.success"));
setTestMailAddress("");
@@ -220,7 +220,7 @@ export default function SettingsView(props) {
<TextField size={"small"} variant={"outlined"}
type={"password"}
disabled={disabled}
placeholder={"(" + L("settings.unchanged") + ")"}
placeholder={"(" + L("general.unchanged") + ")"}
value={settings[key_name]}
onChange={e => onChangeValue(key_name, e.target.value)} />
</FormControl>

View File

@@ -227,7 +227,7 @@ export class SecretsColumn extends DataColumn {
properties.className = clsx(properties.className, "font-monospace");
if (this.canCopy) {
properties.title = L("Click to copy");
properties.title = L("general.click_to_copy");
properties.className = clsx(properties.className, "data-table-clickable");
properties.onClick = () => {
navigator.clipboard.writeText(originalData);