Mail bugfix, gpg, profile frontend WIP

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

@ -55,14 +55,15 @@ namespace Core\API\Mail {
use Core\External\PHPMailer\PHPMailer; use Core\External\PHPMailer\PHPMailer;
use Core\Objects\Context; use Core\Objects\Context;
use Core\Objects\DatabaseEntity\GpgKey; use Core\Objects\DatabaseEntity\GpgKey;
use PhpParser\Node\Param;
class Test extends MailAPI { class Test extends MailAPI {
public function __construct(Context $context, bool $externalCall = false) { public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, [
"receiver" => new Parameter("receiver", Parameter::TYPE_EMAIL), "receiver" => new Parameter("receiver", Parameter::TYPE_EMAIL),
"gpgFingerprint" => new StringType("gpgFingerprint", 64, true, null) "gpgFingerprint" => new StringType("gpgFingerprint", 64, true, null)
)); ]);
} }
public function _execute(): bool { public function _execute(): bool {
@ -74,10 +75,12 @@ namespace Core\API\Mail {
"subject" => "Test E-Mail", "subject" => "Test E-Mail",
"body" => "Hey! If you receive this e-mail, your mail configuration seems to be working.", "body" => "Hey! If you receive this e-mail, your mail configuration seems to be working.",
"gpgFingerprint" => $this->getParam("gpgFingerprint"), "gpgFingerprint" => $this->getParam("gpgFingerprint"),
"async" => false "async" => false,
"debug" => true,
)); ));
$this->lastError = $req->getLastError(); $this->lastError = $req->getLastError();
$this->result["output"] = $req->getResult()["output"];
return $this->success; return $this->success;
} }
@ -95,7 +98,8 @@ namespace Core\API\Mail {
'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null), 'replyTo' => new Parameter('replyTo', Parameter::TYPE_EMAIL, true, null),
'replyName' => new StringType('replyName', 32, true, ""), 'replyName' => new StringType('replyName', 32, true, ""),
'gpgFingerprint' => new StringType("gpgFingerprint", 64, true, null), 'gpgFingerprint' => new StringType("gpgFingerprint", 64, true, null),
'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, null) 'async' => new Parameter("async", Parameter::TYPE_BOOLEAN, true, null),
'debug' => new Parameter("debug", Parameter::TYPE_BOOLEAN, true, false)
)); ));
$this->isPublic = false; $this->isPublic = false;
} }
@ -115,6 +119,7 @@ namespace Core\API\Mail {
$replyName = $this->getParam('replyName'); $replyName = $this->getParam('replyName');
$body = $this->getParam('body'); $body = $this->getParam('body');
$gpgFingerprint = $this->getParam("gpgFingerprint"); $gpgFingerprint = $this->getParam("gpgFingerprint");
$debug = $this->getParam("debug");
$mailAsync = $this->getParam("async"); $mailAsync = $this->getParam("async");
if ($mailAsync === null) { if ($mailAsync === null) {
@ -156,8 +161,9 @@ namespace Core\API\Mail {
$mail->addReplyTo($replyTo, $replyName); $mail->addReplyTo($replyTo, $replyName);
} }
$mail->Subject = $subject; $mail->Subject = $subject;
$mail->SMTPDebug = 0; $mail->SMTPDebug = $debug ? 2 : 0;
$mail->Host = $mailConfig->getHost(); $mail->Host = $mailConfig->getHost();
$mail->Port = $mailConfig->getPort(); $mail->Port = $mailConfig->getPort();
$mail->SMTPAuth = true; $mail->SMTPAuth = true;
@ -193,12 +199,22 @@ namespace Core\API\Mail {
$mail->AltBody = strip_tags($body); $mail->AltBody = strip_tags($body);
} }
ob_start();
$this->success = @$mail->Send(); $this->success = @$mail->Send();
$output = ob_get_contents();
ob_end_clean();
if (!$this->success) { if (!$this->success) {
$this->lastError = "Error sending Mail: $mail->ErrorInfo"; $this->lastError = "Error sending Mail: $mail->ErrorInfo";
$this->logger->error("sendMail() failed: $mail->ErrorInfo"); $this->logger->error("sendMail() failed: $mail->ErrorInfo");
if ($debug) {
$this->logger->debug($output);
$this->result["output"] = $output;
}
} else { } else {
$this->result["messageId"] = $mail->getLastMessageID(); $this->result["messageId"] = $mail->getLastMessageID();
if ($debug) {
$this->result["output"] = $output;
}
} }
} catch (Exception $e) { } catch (Exception $e) {
$this->success = false; $this->success = false;

@ -1240,9 +1240,9 @@ namespace Core\API\User {
class ImportGPG extends UserAPI { class ImportGPG extends UserAPI {
public function __construct(Context $context, bool $externalCall = false) { public function __construct(Context $context, bool $externalCall = false) {
parent::__construct($context, $externalCall, array( parent::__construct($context, $externalCall, [
"pubkey" => new StringType("pubkey") "pubkey" => new StringType("pubkey")
)); ]);
$this->loginRequired = true; $this->loginRequired = true;
$this->forbidMethod("GET"); $this->forbidMethod("GET");
} }
@ -1342,6 +1342,10 @@ namespace Core\API\User {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to import gpg keys for a secure e-mail communication", true);
}
} }
class RemoveGPG extends UserAPI { class RemoveGPG extends UserAPI {
@ -1371,6 +1375,10 @@ namespace Core\API\User {
return $this->success; return $this->success;
} }
public static function getDefaultACL(Insert $insert): void {
$insert->addRow(self::getEndpoint(), [], "Allows users to unlink gpg keys from their profile", true);
}
} }
class ConfirmGPG extends UserAPI { class ConfirmGPG extends UserAPI {

@ -85,4 +85,15 @@ return [
"remove_group_member_text" => "Möchten Sie wirklich den Benutzer '%s' von dieser Gruppe entfernen?", "remove_group_member_text" => "Möchten Sie wirklich den Benutzer '%s' von dieser Gruppe entfernen?",
"add_group_member_title" => "Mitglied hinzufügen", "add_group_member_title" => "Mitglied hinzufügen",
"add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen", "add_group_member_text" => "Einen Benutzer suchen um ihn der Gruppe hinzuzufügen",
# GPG Key
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
# 2fa
"register_2fa_device" => "Ein 2FA-Gerät registrieren",
"register_2fa_totp_text" => "Scan den QR-Code mit einem Gerät, das du als Zwei-Faktor-Authentifizierung (2FA) benutzen willst. " .
"Unter Android kannst du den Google Authenticator benutzen.",
"register_2fa_fido_text" => "Möglicherweise musst du mit dem Gerät interagieren, zum Beispiel durch Eingeben einer PIN oder durch Berühren des Geräts",
"remove_2fa" => "2FA-Token entfernen",
"remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen",
]; ];

@ -34,6 +34,7 @@ return [
"yes" => "Ja", "yes" => "Ja",
"no" => "Nein", "no" => "Nein",
"create_new" => "Erstellen", "create_new" => "Erstellen",
"unchanged" => "Unverändert",
# dialog / actions # dialog / actions
"action" => "Aktion", "action" => "Aktion",

@ -12,17 +12,6 @@ return [
"show_only_active_keys" => "Zeige nur aktive Schlüssel", "show_only_active_keys" => "Zeige nur aktive Schlüssel",
"no_api_key_registered" => "Keine gültigen API-Schlüssel registriert", "no_api_key_registered" => "Keine gültigen API-Schlüssel registriert",
# GPG Key
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",
# 2fa
"register_2fa_device" => "Ein 2FA-Gerät registrieren",
"register_2fa_totp_text" => "Scan den QR-Code mit einem Gerät, das du als Zwei-Faktor-Authentifizierung (2FA) benutzen willst. " .
"Unter Android kannst du den Google Authenticator benutzen.",
"register_2fa_fido_text" => "Möglicherweise musst du mit dem Gerät interagieren, zum Beispiel durch Eingeben einer PIN oder durch Berühren des Geräts",
"remove_2fa" => "2FA-Token entfernen",
"remove_2fa_text" => "Gib dein aktuelles Passwort ein um das Entfernen des 2FA-Tokens zu bestätigen",
# settings # settings
"key" => "Schlüssel", "key" => "Schlüssel",
"value" => "Wert", "value" => "Wert",
@ -30,7 +19,6 @@ return [
"mail" => "Mail", "mail" => "Mail",
"recaptcha" => "reCaptcha", "recaptcha" => "reCaptcha",
"uncategorized" => "Unkategorisiert", "uncategorized" => "Unkategorisiert",
"unchanged" => "Unverändert",
# general settings # general settings
"site_name" => "Seitenname", "site_name" => "Seitenname",

@ -85,4 +85,15 @@ return [
"remove_group_member_text" => "Do you really want to remove user '%s' from this group?", "remove_group_member_text" => "Do you really want to remove user '%s' from this group?",
"add_group_member_title" => "Add member", "add_group_member_title" => "Add member",
"add_group_member_text" => "Search a user to add to the group", "add_group_member_text" => "Search a user to add to the group",
# GPG Key
"gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
# 2fa
"register_2fa_device" => "Register a 2FA-Device",
"register_2fa_totp_text" => "Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " .
"On Android, you can use the Google Authenticator.",
"register_2fa_fido_text" => "You may need to interact with your Device, e.g. typing in your PIN or touching to confirm the registration.",
"remove_2fa" => "Remove 2FA Token",
"remove_2fa_text" => "Enter your current password to confirm the removal of your 2FA Token",
]; ];

@ -16,6 +16,7 @@ return [
"yes" => "Yes", "yes" => "Yes",
"no" => "No", "no" => "No",
"create_new" => "Create", "create_new" => "Create",
"unchanged" => "Unchanged",
# dialog / actions # dialog / actions
"action" => "Action", "action" => "Action",

@ -12,17 +12,6 @@ return [
"show_only_active_keys" => "Show only active keys", "show_only_active_keys" => "Show only active keys",
"no_api_key_registered" => "No valid API-Keys registered", "no_api_key_registered" => "No valid API-Keys registered",
# GPG Key
"gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",
# 2fa
"register_2fa_device" => "Register a 2FA-Device",
"register_2fa_totp_text" => "Scan the QR-Code with a device you want to use for Two-Factor-Authentication (2FA). " .
"On Android, you can use the Google Authenticator.",
"register_2fa_fido_text" => "You may need to interact with your Device, e.g. typing in your PIN or touching to confirm the registration.",
"remove_2fa" => "Remove 2FA Token",
"remove_2fa_text" => "Enter your current password to confirm the removal of your 2FA Token",
# settings # settings
"key" => "Key", "key" => "Key",
"value" => "Value", "value" => "Value",
@ -30,7 +19,6 @@ return [
"mail" => "Mail", "mail" => "Mail",
"recaptcha" => "reCaptcha", "recaptcha" => "reCaptcha",
"uncategorized" => "Uncategorized", "uncategorized" => "Uncategorized",
"unchanged" => "Unchanged",
# general settings # general settings
"site_name" => "Site Name", "site_name" => "Site Name",

@ -16,7 +16,6 @@ Web-Base is a php framework which provides basic web functionalities and a moder
- [Command Line Interface (CLI)](#cli) - [Command Line Interface (CLI)](#cli)
- [Account & User functions](#access-control) - [Account & User functions](#access-control)
- Admin Dashboard - Admin Dashboard
- File Sharing Dashboard
- Docker Support - Docker Support
### Upcoming: ### Upcoming:

30
cli.php

@ -630,8 +630,33 @@ function onMail($argv): void {
if (!$req->execute(["debug" => $debug])) { if (!$req->execute(["debug" => $debug])) {
_exit("Error processing mail queue: " . $req->getLastError()); _exit("Error processing mail queue: " . $req->getLastError());
} }
} else if ($action === "test") {
$recipient = $argv[3] ?? null;
$gpgFingerprint = $argv[4] ?? null;
if (!$recipient) {
_exit("Usage: cli.php mail test <recipient> [gpg-fingerprint]");
}
connectSQL() or die();
$req = new \Core\API\Mail\Test($context);
$success = $req->execute([
"receiver" => $recipient,
"gpgFingerprint" => $gpgFingerprint,
]);
$result = $req->getResult();
if ($success) {
printLine("Test email sent successfully");
} else { } else {
_exit("Usage: cli.php mail <send_queue> [options...]"); printLine("Test email failed to sent: " . $req->getLastError());
}
if (array_key_exists("output", $result)) {
printLine();
printLine($result["output"]);
}
} else {
_exit("Usage: cli.php mail <send_queue|test> [options...]");
} }
} }
@ -643,7 +668,6 @@ function onImpersonate($argv): void {
} }
$sql = connectSQL() or die(); $sql = connectSQL() or die();
$userId = $argv[2]; $userId = $argv[2];
if (!is_numeric($userId)) { if (!is_numeric($userId)) {
$res = $sql->select("id") $res = $sql->select("id")
@ -932,7 +956,7 @@ $registeredCommands = [
"routes" => ["handler" => "onRoutes", "description" => "view and modify routes"], "routes" => ["handler" => "onRoutes", "description" => "view and modify routes"],
"maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode"], "maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode"],
"test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true], "test" => ["handler" => "onTest", "description" => "run unit and integration tests", "requiresDocker" => true],
"mail" => ["handler" => "onMail", "description" => "send mails and process the pipeline"], "mail" => ["handler" => "onMail", "description" => "send mails and process the pipeline", "requiresDocker" => true],
"settings" => ["handler" => "onSettings", "description" => "change and view settings"], "settings" => ["handler" => "onSettings", "description" => "change and view settings"],
"impersonate" => ["handler" => "onImpersonate", "description" => "create a session and print cookies and csrf tokens", "requiresDocker" => true], "impersonate" => ["handler" => "onImpersonate", "description" => "create a session and print cookies and csrf tokens", "requiresDocker" => true],
"frontend" => ["handler" => "onFrontend", "description" => "build and manage frontend modules"], "frontend" => ["handler" => "onFrontend", "description" => "build and manage frontend modules"],

@ -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;

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

@ -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>
}

@ -1,8 +1,62 @@
import {Link} from "react-router-dom"; import {Link} from "react-router-dom";
import React, {useCallback, useContext, useEffect, useState} from "react"; import React, {useCallback, useContext, useEffect, useState} from "react";
import {LocaleContext} from "shared/locale"; import {LocaleContext} from "shared/locale";
import {Button, CircularProgress, FormControl, FormGroup, FormLabel, TextField} from "@mui/material"; import {
import {Save} from "@mui/icons-material"; 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) { export default function ProfileView(props) {
@ -22,9 +76,14 @@ export default function ProfileView(props) {
// data // data
const [profile, setProfile] = useState({...api.user}); const [profile, setProfile] = useState({...api.user});
const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" }); const [changePassword, setChangePassword] = useState({ old: "", new: "", confirm: "" });
const [gpgKey, setGpgKey] = useState("");
const [gpgKeyPassword, setGpgKeyPassword] = useState("");
// ui // ui
const [openedTab, setOpenedTab] = useState(null);
const [isSaving, setSaving] = useState(false); const [isSaving, setSaving] = useState(false);
const [isGpgKeyUploading, setGpgKeyUploading] = useState(false);
const [isGpgKeyRemoving, setGpgKeyRemoving] = useState(false);
const onUpdateProfile = useCallback(() => { const onUpdateProfile = useCallback(() => {
@ -60,6 +119,54 @@ export default function ProfileView(props) {
}, [profile, changePassword, api, showDialog, isSaving]); }, [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 <> return <>
<div className={"content-header"}> <div className={"content-header"}>
<div className={"container-fluid"}> <div className={"container-fluid"}>
@ -79,7 +186,7 @@ export default function ProfileView(props) {
</div> </div>
</div> </div>
<div className={"content"}> <div className={"content"}>
<FormGroup> <ProfileFormGroup>
<FormLabel>{L("account.username")}</FormLabel> <FormLabel>{L("account.username")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
@ -87,8 +194,8 @@ export default function ProfileView(props) {
value={profile.name} value={profile.name}
onChange={e => setProfile({...profile, name: e.target.value })} /> onChange={e => setProfile({...profile, name: e.target.value })} />
</FormControl> </FormControl>
</FormGroup> </ProfileFormGroup>
<FormGroup> <ProfileFormGroup>
<FormLabel>{L("account.full_name")}</FormLabel> <FormLabel>{L("account.full_name")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
@ -96,10 +203,13 @@ export default function ProfileView(props) {
value={profile.fullName ?? ""} value={profile.fullName ?? ""}
onChange={e => setProfile({...profile, fullName: e.target.value })} /> onChange={e => setProfile({...profile, fullName: e.target.value })} />
</FormControl> </FormControl>
</FormGroup> </ProfileFormGroup>
<h4>{L("account.change_password")}</h4>
<FormGroup> <CollapseBox title={L("account.change_password")} open={openedTab === "password"}
<FormLabel>{L("account.old_password")}</FormLabel> onToggle={() => setOpenedTab(openedTab === "password" ? "" : "password")}
icon={<Password />}>
<ProfileFormGroup>
<FormLabel>{L("account.password_old")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
size={"small"} size={"small"}
@ -108,9 +218,9 @@ export default function ProfileView(props) {
value={changePassword.old} value={changePassword.old}
onChange={e => setChangePassword({...changePassword, old: e.target.value })} /> onChange={e => setChangePassword({...changePassword, old: e.target.value })} />
</FormControl> </FormControl>
</FormGroup> </ProfileFormGroup>
<FormGroup> <ProfileFormGroup>
<FormLabel>{L("account.new_password")}</FormLabel> <FormLabel>{L("account.password_new")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
size={"small"} size={"small"}
@ -118,24 +228,104 @@ export default function ProfileView(props) {
value={changePassword.new} value={changePassword.new}
onChange={e => setChangePassword({...changePassword, new: e.target.value })} /> onChange={e => setChangePassword({...changePassword, new: e.target.value })} />
</FormControl> </FormControl>
</FormGroup> </ProfileFormGroup>
<FormGroup> <ProfileFormGroup>
<FormLabel>{L("account.confirm_password")}</FormLabel> <FormLabel>{L("account.password_confirm")}</FormLabel>
<FormControl> <FormControl>
<TextField variant={"outlined"} <TextField variant={"outlined"}
size={"small"} size={"small"}
type={"password"} type={"password"}
placeholder={L("general.unchanged")}
value={changePassword.confirm} value={changePassword.confirm}
onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} /> onChange={e => setChangePassword({...changePassword, confirm: e.target.value })} />
</FormControl> </FormControl>
</FormGroup> </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"} <Button variant={"outlined"} color={"primary"}
disabled={isSaving || !api.hasPermission("user/updateProfile")} disabled={isSaving || !api.hasPermission("user/updateProfile")}
startIcon={isSaving ? <CircularProgress size={12} /> : <Save />} startIcon={isSaving ? <CircularProgress size={12} /> : <Save />}
onClick={onUpdateProfile}> onClick={onUpdateProfile}>
{isSaving ? L("general.saving") + "…" : L("general.save")} {isSaving ? L("general.saving") + "…" : L("general.save")}
</Button> </Button>
</Box>
</div> </div>
</> </>
} }

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

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

@ -227,7 +227,7 @@ export class SecretsColumn extends DataColumn {
properties.className = clsx(properties.className, "font-monospace"); properties.className = clsx(properties.className, "font-monospace");
if (this.canCopy) { 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.className = clsx(properties.className, "data-table-clickable");
properties.onClick = () => { properties.onClick = () => {
navigator.clipboard.writeText(originalData); navigator.clipboard.writeText(originalData);