bugfix, profile picture WIP, profile frontend refactored

This commit is contained in:
2024-04-14 20:31:16 +02:00
parent c892ef5b6e
commit 29c72d13e7
25 changed files with 784 additions and 402 deletions

View File

@@ -2,7 +2,8 @@ import React, {useCallback, useContext} from 'react';
import {Link, NavLink} from "react-router-dom";
import Icon from "shared/elements/icon";
import {LocaleContext} from "shared/locale";
import {Avatar, styled} from "@mui/material";
import {styled} from "@mui/material";
import ProfilePicture from "shared/elements/profile-picture";
const ProfileLink = styled(Link)((props) => ({
"& > div": {
@@ -114,7 +115,7 @@ export default function Sidebar(props) {
<div className="info">
<div className={"d-block text-light"}>{L("account.logged_in_as")}:</div>
<ProfileLink to={"/admin/profile"}>
<Avatar fontSize={"small"} />
<ProfilePicture user={api.user} />
<span>{api.user?.name || L("account.not_logged_in")}</span>
</ProfileLink>
</div>

View File

@@ -78,16 +78,19 @@ export default function LogView(props) {
let column = new DataColumn(L("logs.message"), "message");
column.sortable = false;
column.renderData = (L, entry) => {
let lines = entry.message.trim().split("\n");
return <Box display={"grid"} gridTemplateColumns={"40px auto"}>
<Box alignSelf={"top"} textAlign={"center"}>
<IconButton size={"small"} onClick={() => onToggleDetails(entry)}
title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}>
{entry.showDetails ? <ExpandLess /> : <ExpandMore />}
</IconButton>
{lines.length > 1 &&
<IconButton size={"small"} onClick={() => onToggleDetails(entry)}
title={L(entry.showDetails ? "logs.hide_details" : "logs.show_details")}>
{entry.showDetails ? <ExpandLess /> : <ExpandMore />}
</IconButton>
}
</Box>
<Box alignSelf={"center"}>
<pre>
{entry.showDetails ? entry.message : entry.message.split("\n")[0]}
{entry.showDetails ? entry.message : lines[0]}
</pre>
</Box>
</Box>

View File

@@ -0,0 +1,53 @@
import {Password} from "@mui/icons-material";
import SpacedFormGroup from "../../elements/form-group";
import {Box, FormControl, FormLabel, TextField} from "@mui/material";
import PasswordStrength from "shared/elements/password-strength";
import CollapseBox from "./collapse-box";
import React, {useContext} from "react";
import {LocaleContext} from "shared/locale";
export default function ChangePasswordBox(props) {
// meta
const {changePassword, setChangePassword, ...other} = props;
const {translate: L} = useContext(LocaleContext);
return <CollapseBox title={L("account.change_password")}
icon={<Password />}
{...other} >
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<Box className={"w-50"}>
<PasswordStrength password={changePassword.new} minLength={6} />
</Box>
</CollapseBox>
}

View File

@@ -0,0 +1,131 @@
import {Box, Button, CircularProgress, Slider, styled} from "@mui/material";
import {useCallback, useContext, useRef, useState} from "react";
import {LocaleContext} from "shared/locale";
import PreviewProfilePicture from "./preview-picture";
import {Delete, Edit} from "@mui/icons-material";
import ProfilePicture from "shared/elements/profile-picture";
const ProfilePictureBox = styled(Box)((props) => ({
padding: props.theme.spacing(2),
display: "grid",
gridTemplateRows: "auto 60px",
gridGap: props.theme.spacing(2),
textAlign: "center",
}));
const VerticalButtonBar = styled(Box)((props) => ({
"& > button": {
width: "100%",
marginBottom: props.theme.spacing(1),
}
}));
export default function EditProfilePicture(props) {
// meta
const {translate: L} = useContext(LocaleContext);
// const [scale, setScale] = useState(100);
const scale = useRef(100);
const {api, showDialog, setProfile, profile, setDialogData, ...other} = props
const onUploadPicture = useCallback((data) => {
api.uploadPicture(data, scale.current / 100.0).then((res) => {
if (!res.success) {
showDialog(res.msg, L("Error uploading profile picture"));
} else {
setProfile({...profile, profilePicture: res.profilePicture});
}
})
}, [api, scale.current, showDialog, profile]);
const onRemoveImage = useCallback(() => {
api.removePicture().then((res) => {
if (!res.success) {
showDialog(res.msg, L("Error removing profile picture"));
} else {
setProfile({...profile, profilePicture: null});
}
});
}, [api, showDialog, profile]);
const onOpenDialog = useCallback((file = null, data = null) => {
let img = null;
if (data !== null) {
img = new Image();
img.src = data;
}
setDialogData({
show: true,
title: L("account.change_picture_title"),
text: L("account.change_picture_text"),
options: data === null ? [L("general.cancel")] : [L("general.apply"), L("general.cancel")],
inputs: data === null ? [{
key: "pfp-loading",
type: "custom",
element: CircularProgress,
}] : [
{
key: "pfp-preview",
type: "custom",
element: PreviewProfilePicture,
img: img,
scale: scale.current,
setScale: (v) => scale.current = v,
},
],
onOption: (option) => {
if (option === 0 && file) {
onUploadPicture(file)
}
// scale.current = 100;
}
})
}, [setDialogData, onUploadPicture]);
const onSelectImage = useCallback(() => {
let fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = "image/jpeg,image/jpg,image/png";
fileInput.onchange = () => {
let file = fileInput.files[0];
if (file) {
let reader = new FileReader();
reader.onload = function (e) {
onOpenDialog(file, e.target.result);
}
onOpenDialog();
reader.readAsDataURL(file);
}
};
fileInput.click();
}, [onOpenDialog]);
return <ProfilePictureBox {...other}>
<ProfilePicture user={profile} onClick={onSelectImage} />
<VerticalButtonBar>
<Button variant="outlined" size="small"
startIcon={<Edit />}
onClick={onSelectImage}>
{L("account.change_picture")}
</Button>
{profile.profilePicture &&
<Button variant="outlined" size="small"
startIcon={<Delete />} color="secondary"
onClick={() => setDialogData({
show: true,
title: L("account.picture_remove_title"),
message: L("account.picture_remove_text"),
options: [L("general.confirm"), L("general.cancel")],
onOption: (option) => option === 0 ? onRemoveImage() : true
})}>
{L("account.remove_picture")}
</Button>
}
</VerticalButtonBar>
</ProfilePictureBox>
}

View File

@@ -0,0 +1,172 @@
import React, {useCallback, useContext, useState} from "react";
import {LocaleContext} from "shared/locale";
import {Box, Button, CircularProgress, FormControl, FormLabel, styled, TextField} from "@mui/material";
import {CheckCircle, CloudUpload, ErrorOutline, Remove, Upload, VpnKey} from "@mui/icons-material";
import SpacedFormGroup from "../../elements/form-group";
import ButtonBar from "../../elements/button-bar";
import CollapseBox from "./collapse-box";
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 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
const {profile, setProfile, api, showDialog, ...other} = props;
const {translate: L} = useContext(LocaleContext);
// data
const [gpgKey, setGpgKey] = useState("");
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
// ui
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
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, profile]);
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 <CollapseBox title={L("account.gpg_key")} {...other}
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>
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<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>
<SpacedFormGroup>
<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;
}}/>
</SpacedFormGroup>
<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>
}

View File

@@ -0,0 +1,99 @@
import React, {useCallback, useContext, useState} from "react";
import {LocaleContext} from "shared/locale";
import {CheckCircle, ErrorOutline, Fingerprint, Remove} from "@mui/icons-material";
import {Box, Button, CircularProgress, FormControl, FormLabel, styled, TextField} from "@mui/material";
import SpacedFormGroup from "../../elements/form-group";
import MfaTotp from "./mfa-totp";
import MfaFido from "./mfa-fido";
import CollapseBox from "./collapse-box";
const MfaStatusBox = styled(Box)((props) => ({
"& > svg": {
marginRight: props.theme.spacing(1),
},
}));
const MFAOptions = styled(Box)((props) => ({
"& > div": {
borderColor: props.theme.palette.divider,
borderStyle: "solid",
borderWidth: 1,
borderRadius: 5,
maxWidth: 150,
cursor: "pointer",
textAlign: "center",
display: "inline-grid",
gridTemplateRows: "130px 50px",
alignItems: "center",
padding: props.theme.spacing(1),
marginRight: props.theme.spacing(1),
"&:hover": {
backgroundColor: "lightgray",
},
"& img": {
width: "100%",
},
}
}));
export default function MultiFactorBox(props) {
// meta
const {profile, setProfile, setDialogData, api, showDialog, ...other} = props;
const {translate: L} = useContext(LocaleContext);
// data
const [mfaPassword, set2FAPassword] = useState("");
// ui
const [is2FARemoving, set2FARemoving] = useState(false);
const onRemove2FA = useCallback(() => {
if (!is2FARemoving) {
set2FARemoving(true);
api.remove2FA(mfaPassword).then(data => {
set2FARemoving(false);
set2FAPassword("");
if (!data.success) {
showDialog(data.msg, L("account.remove_2fa_error"));
} else {
setProfile({...profile, twoFactorToken: null});
}
});
}
}, [api, showDialog, is2FARemoving, mfaPassword, profile]);
return <CollapseBox title={L("account.2fa_token")}
icon={<Fingerprint />} {...other}>
{profile.twoFactorToken && profile.twoFactorToken.confirmed ?
<Box>
<MfaStatusBox mb={2}>
<CheckCircle color="info" title={L("account.two_factor_confirmed")} />
{L("account.2fa_type_" + profile.twoFactorToken.type)}
</MfaStatusBox>
<SpacedFormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField variant={"outlined"} size="small"
value={mfaPassword} type={"password"}
onChange={e => set2FAPassword(e.target.value)}
placeholder={L("account.password")}
/>
</FormControl>
</SpacedFormGroup>
<Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
color="secondary" onClick={onRemove2FA}
variant="outlined" size="small"
disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
{is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
</Button>
</Box> :
<MFAOptions>
<MfaTotp api={api} showDialog={showDialog} setDialogData={setDialogData}
set2FA={token => setProfile({...profile, twoFactorToken: token })} />
<MfaFido api={api} showDialog={showDialog} setDialogData={setDialogData}
set2FA={token => setProfile({...profile, twoFactorToken: token })} />
</MFAOptions>
}
</CollapseBox>
}

View File

@@ -38,11 +38,15 @@ export default function MfaFido(props) {
challenge: encodeText(window.atob(res.data.challenge)),
rp: res.data.relyingParty,
user: {
id: encodeText(res.data.id),
id: encodeText(api.user.id),
name: api.user.name,
displayName: api.user.fullName
},
userVerification: "discouraged",
authenticatorSelection: {
authenticatorAttachment: "cross-platform",
requireResidentKey: false,
userVerification: "discouraged"
},
attestation: "direct",
pubKeyCredParams: [{
type: "public-key",

View File

@@ -24,9 +24,8 @@ export default function MfaTotp(props) {
if (api.hasPermission("tfa/generateQR")) {
setDialogData({
show: true,
title: L("Register a 2FA-Device"),
message: L("Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " +
"On Android, you can use the Google Authenticator."),
title: L("account.register_2fa_device"),
message: L("account.register_2fa_totp_text"),
inputs: [
{
type: "custom", element: Box, textAlign: "center", key: "qr-code",

View File

@@ -0,0 +1,53 @@
import {Box, Slider, styled} from "@mui/material";
import {useContext, useState} from "react";
import {LocaleContext} from "shared/locale";
const PreviewProfilePictureBox = styled(Box)((props) => ({
position: "relative",
}));
const PictureBox = styled(Box)((props) => ({
backgroundRepeat: "no-repeat",
backgroundSize: "contain"
}));
const SelectionBox = styled(Box)((props) => ({
position: "absolute",
border: "1px solid black",
borderRadius: "50%",
}));
export default function PreviewProfilePicture(props) {
const {translate: L} = useContext(LocaleContext);
const {img, scale, setScale, ...other} = props;
let size = "auto";
let displaySize = ["auto", "auto"];
let offsetY = 0;
let offsetX = 0;
if (img) {
displaySize[0] = Math.min(img.naturalWidth, 400);
displaySize[1] = img.naturalHeight * (displaySize[0] / img.naturalWidth);
size = Math.min(...displaySize) * (scale / 100.0);
offsetX = displaySize[0] / 2 - size / 2;
offsetY = displaySize[1] / 2 - size / 2;
}
return <PreviewProfilePictureBox {...other} textAlign={"center"}>
<PictureBox width={displaySize[0]} height={displaySize[1]}
sx={{backgroundImage: `url("${img.src}")`, width: displaySize[0], height: displaySize[1], filter: "blur(5px)"}}
title={L("account.profile_picture_preview")} />
<PictureBox width={displaySize[0]} height={displaySize[1]}
position={"absolute"} top={0} left={0}
sx={{backgroundImage: `url("${img.src}")`, width: displaySize[0], height: displaySize[1],
clipPath: `circle(${scale*0.50}%)`}}
title={L("account.profile_picture_preview")} />
<SelectionBox width={size} height={size} top={offsetY} left={offsetX} />
<Box mt={1}>
<label>{L("account.profile_picture_scale")}: {scale}%</label>
<Slider value={scale} min={50} max={100} onChange={e => setScale(e.target.value)} />
</Box>
</PreviewProfilePictureBox>
}

View File

@@ -6,81 +6,18 @@ import {
Button,
CircularProgress,
FormControl,
FormGroup,
FormLabel, styled,
FormLabel,
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";
import MfaTotp from "./mfa-totp";
import MfaFido from "./mfa-fido";
import Dialog from "shared/elements/dialog";
import PasswordStrength from "shared/elements/password-strength";
import SpacedFormGroup from "../../elements/form-group";
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 MFAOptions = styled(Box)((props) => ({
"& > div": {
borderColor: props.theme.palette.divider,
borderStyle: "solid",
borderWidth: 1,
borderRadius: 5,
maxWidth: 150,
cursor: "pointer",
textAlign: "center",
display: "inline-grid",
gridTemplateRows: "130px 50px",
alignItems: "center",
padding: props.theme.spacing(1),
marginRight: props.theme.spacing(1),
"&:hover": {
backgroundColor: "lightgray",
},
"& img": {
width: "100%",
},
}
}));
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,
});
import ChangePasswordBox from "./change-password-box";
import GpgBox from "./gpg-box";
import MultiFactorBox from "./mfa-box";
import EditProfilePicture from "./edit-picture";
export default function ProfileView(props) {
@@ -100,16 +37,10 @@ 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("");
const [mfaPassword, set2FAPassword] = useState("");
// ui
const [openedTab, setOpenedTab] = useState(null);
const [isSaving, setSaving] = useState(false);
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
const [is2FARemoving, set2FARemoving] = useState(false);
const [dialogData, setDialogData] = useState({show: false});
const onUpdateProfile = useCallback(() => {
@@ -146,69 +77,6 @@ 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, profile]);
const onRemove2FA = useCallback(() => {
if (!is2FARemoving) {
set2FARemoving(true);
api.remove2FA(mfaPassword).then(data => {
set2FARemoving(false);
set2FAPassword("");
if (!data.success) {
showDialog(data.msg, L("account.remove_2fa_error"));
} else {
setProfile({...profile, twoFactorToken: null});
}
});
}
}, [api, showDialog, is2FARemoving, mfaPassword, profile]);
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"}>
@@ -227,173 +95,56 @@ export default function ProfileView(props) {
</div>
</div>
</div>
<div className={"content"}>
<SpacedFormGroup>
<FormLabel>{L("account.username")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} />
</FormControl>
</SpacedFormGroup>
<SpacedFormGroup>
<FormLabel>{L("account.full_name")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
value={profile.fullName ?? ""}
onChange={e => setProfile({...profile, fullName: e.target.value })} />
</FormControl>
</SpacedFormGroup>
<CollapseBox title={L("account.change_password")} open={openedTab === "password"}
onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
icon={<Password />}>
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<Box className={"w-50"}>
<PasswordStrength password={changePassword.new} minLength={6} />
<Box>
<Box display={"grid"} gridTemplateColumns={"300px auto"}>
<EditProfilePicture api={api} showDialog={showDialog} setProfile={setProfile}
profile={profile} setDialogData={setDialogData} />
<Box p={2}>
<SpacedFormGroup>
<FormLabel>{L("account.username")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} />
</FormControl>
</SpacedFormGroup>
<SpacedFormGroup>
<FormLabel>{L("account.full_name")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
value={profile.fullName ?? ""}
onChange={e => setProfile({...profile, fullName: e.target.value })} />
</FormControl>
</SpacedFormGroup>
<SpacedFormGroup>
<FormLabel>{L("account.email")}</FormLabel>
<FormControl>
<TextField variant={"outlined"}
size={"small"}
value={profile.email ?? ""}
disabled={true}/>
</FormControl>
</SpacedFormGroup>
</Box>
</CollapseBox>
</Box>
<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>
<SpacedFormGroup>
<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>
</SpacedFormGroup>
<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>
<SpacedFormGroup>
<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;
}}/>
</SpacedFormGroup>
<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>
<ChangePasswordBox open={openedTab === "password"}
onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
changePassword={changePassword}
setChangePassword={setChangePassword} />
<CollapseBox title={L("account.2fa_token")} open={openedTab === "2fa"}
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
icon={<Fingerprint />}>
{profile.twoFactorToken && profile.twoFactorToken.confirmed ?
<Box>
<GpgFingerprintBox mb={2}>
{ profile.twoFactorToken.confirmed ?
<CheckCircle color={"info"} title={L("account.gpg_key_confirmed")} /> :
<ErrorOutline color={"secondary"} title={L("account.gpg_key_pending")} />
}
{L("account.2fa_type_" + profile.twoFactorToken.type)}
</GpgFingerprintBox>
<SpacedFormGroup>
<FormLabel>{L("account.password")}</FormLabel>
<FormControl>
<TextField variant={"outlined"} size={"small"}
value={mfaPassword} type={"password"}
onChange={e => set2FAPassword(e.target.value)}
placeholder={L("account.password")}
/>
</FormControl>
</SpacedFormGroup>
<Button startIcon={is2FARemoving ? <CircularProgress size={12} /> : <Remove />}
color={"secondary"} onClick={onRemove2FA}
variant={"outlined"} size={"small"}
disabled={is2FARemoving || !api.hasPermission("tfa/remove")}>
{is2FARemoving ? L("general.removing") + "…" : L("general.remove")}
</Button>
</Box> :
<MFAOptions>
<MfaTotp api={api} showDialog={showDialog} setDialogData={setDialogData}
set2FA={token => setProfile({...profile, twoFactorToken: token })} />
<MfaFido api={api} showDialog={showDialog} setDialogData={setDialogData}
set2FA={token => setProfile({...profile, twoFactorToken: token })} />
</MFAOptions>
}
</CollapseBox>
<GpgBox open={openedTab === "gpg"}
onToggle={() => setOpenedTab(openedTab === "gpg" ? "" : "gpg")}
profile={profile} setProfile={setProfile}
api={api} showDialog={showDialog} />
<MultiFactorBox open={openedTab === "2fa"}
onToggle={() => setOpenedTab(openedTab === "2fa" ? "" : "2fa")}
profile={profile} setProfile={setProfile}
setDialogData={setDialogData}
api={api} showDialog={showDialog} />
<Box mt={2}>
<Button variant={"outlined"} color={"primary"}
@@ -403,7 +154,7 @@ export default function ProfileView(props) {
{isSaving ? L("general.saving") + "…" : L("general.save")}
</Button>
</Box>
</div>
</Box>
<Dialog show={dialogData.show}
title={dialogData.title}

View File

@@ -18,7 +18,7 @@ export default class API {
return this.loggedIn ? this.session.csrfToken : null;
}
async apiCall(method, params, expectBinary=false) {
async apiCall(method, params = {}, expectBinary=false) {
params = params || { };
const csrfToken = this.csrfToken();
const config = {method: 'post'};

View File

@@ -0,0 +1,45 @@
import {Box, styled} from "@mui/material";
import {useContext} from "react";
import {LocaleContext} from "../locale";
const PictureBox = styled("img")({
width: "100%",
clipPath: "circle(50%)",
});
const PicturePlaceholderBox = styled(Box)((props) => ({
display: "flex",
justifyContent: "center",
alignItems: "center",
background: "radial-gradient(circle closest-side, gray 98%, transparent 100%);",
containerType: "inline-size",
"& > span": {
textAlign: "center",
fontSize: "30cqw",
color: "black",
}
}));
export default function ProfilePicture(props) {
const {user, ...other} = props;
const {translate: L} = useContext(LocaleContext);
const initials = (user.fullName || user.name)
.split(" ")
.map(n => n.charAt(0).toUpperCase())
.join("");
const isClickable = !!other.onClick;
const sx = isClickable ? {cursor: "pointer"} : {};
if (user.profilePicture) {
return <PictureBox src={`/img/uploads/user/${user.id}/${user.profilePicture}`} sx={sx}
alt={L("account.profile_picture_of") + " " + (user.fullName || user.name)}
{...other} />;
} else {
return <PicturePlaceholderBox sx={sx} {...other}>
<span>{initials}</span>
</PicturePlaceholderBox>;
}
}

View File

@@ -139,7 +139,6 @@ export default function LoginForm(props) {
type: "public-key",
}],
userVerification: "discouraged",
attestation: "direct",
},
signal: abortSignal
}).then((res) => {