bugfix, profile picture WIP, profile frontend refactored
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
53
react/admin-panel/src/views/profile/change-password-box.js
Normal file
53
react/admin-panel/src/views/profile/change-password-box.js
Normal 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>
|
||||
}
|
||||
131
react/admin-panel/src/views/profile/edit-picture.js
Normal file
131
react/admin-panel/src/views/profile/edit-picture.js
Normal 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>
|
||||
}
|
||||
172
react/admin-panel/src/views/profile/gpg-box.js
Normal file
172
react/admin-panel/src/views/profile/gpg-box.js
Normal 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>
|
||||
}
|
||||
99
react/admin-panel/src/views/profile/mfa-box.js
Normal file
99
react/admin-panel/src/views/profile/mfa-box.js
Normal 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>
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
53
react/admin-panel/src/views/profile/preview-picture.js
Normal file
53
react/admin-panel/src/views/profile/preview-picture.js
Normal 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>
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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'};
|
||||
|
||||
45
react/shared/elements/profile-picture.js
Normal file
45
react/shared/elements/profile-picture.js
Normal 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>;
|
||||
}
|
||||
}
|
||||
@@ -139,7 +139,6 @@ export default function LoginForm(props) {
|
||||
type: "public-key",
|
||||
}],
|
||||
userVerification: "discouraged",
|
||||
attestation: "direct",
|
||||
},
|
||||
signal: abortSignal
|
||||
}).then((res) => {
|
||||
|
||||
Reference in New Issue
Block a user