Browse Source

Mail bugfix, gpg, profile frontend WIP

Roman 3 weeks ago
parent
commit
e97ac34365

+ 21 - 5
Core/API/MailAPI.class.php

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

+ 10 - 2
Core/API/UserAPI.class.php

@@ -1240,9 +1240,9 @@ namespace Core\API\User {
   class ImportGPG extends UserAPI {
 
     public function __construct(Context $context, bool $externalCall = false) {
-      parent::__construct($context, $externalCall, array(
+      parent::__construct($context, $externalCall, [
         "pubkey" => new StringType("pubkey")
-      ));
+      ]);
       $this->loginRequired = true;
       $this->forbidMethod("GET");
     }
@@ -1342,6 +1342,10 @@ namespace Core\API\User {
 
       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 {
@@ -1371,6 +1375,10 @@ namespace Core\API\User {
 
       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 {

+ 11 - 0
Core/Localization/de_DE/account.php

@@ -85,4 +85,15 @@ return [
   "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_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",
 ];

+ 1 - 0
Core/Localization/de_DE/general.php

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

+ 0 - 12
Core/Localization/de_DE/settings.php

@@ -12,17 +12,6 @@ return [
   "show_only_active_keys" => "Zeige nur aktive Schlüssel",
   "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
   "key" => "Schlüssel",
   "value" => "Wert",
@@ -30,7 +19,6 @@ return [
   "mail" => "Mail",
   "recaptcha" => "reCaptcha",
   "uncategorized" => "Unkategorisiert",
-  "unchanged" => "Unverändert",
 
   # general settings
   "site_name" => "Seitenname",

+ 11 - 0
Core/Localization/en_US/account.php

@@ -85,4 +85,15 @@ return [
   "remove_group_member_text" => "Do you really want to remove user '%s' from this group?",
   "add_group_member_title" => "Add member",
   "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",
 ];

+ 1 - 0
Core/Localization/en_US/general.php

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

+ 0 - 12
Core/Localization/en_US/settings.php

@@ -12,17 +12,6 @@ return [
   "show_only_active_keys" => "Show only active keys",
   "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
   "key" => "Key",
   "value" => "Value",
@@ -30,7 +19,6 @@ return [
   "mail" => "Mail",
   "recaptcha" => "reCaptcha",
   "uncategorized" => "Uncategorized",
-  "unchanged" => "Unchanged",
 
   # general settings
   "site_name" => "Site Name",

+ 0 - 1
README.md

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

+ 27 - 3
cli.php

@@ -630,8 +630,33 @@ function onMail($argv): void {
     if (!$req->execute(["debug" => $debug])) {
       _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 {
+      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> [options...]");
+    _exit("Usage: cli.php mail <send_queue|test> [options...]");
   }
 }
 
@@ -643,7 +668,6 @@ function onImpersonate($argv): void {
   }
 
   $sql = connectSQL() or die();
-
   $userId = $argv[2];
   if (!is_numeric($userId)) {
     $res = $sql->select("id")
@@ -932,7 +956,7 @@ $registeredCommands = [
   "routes" => ["handler" => "onRoutes", "description" => "view and modify routes"],
   "maintenance" => ["handler" => "onMaintenance", "description" => "toggle maintenance mode"],
   "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"],
   "impersonate" => ["handler" => "onImpersonate", "description" => "create a session and print cookies and csrf tokens", "requiresDocker" => true],
   "frontend" => ["handler" => "onFrontend", "description" => "build and manage frontend modules"],

+ 9 - 0
react/admin-panel/src/elements/button-bar.js

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

+ 2 - 7
react/admin-panel/src/views/group/group-edit.js

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

+ 39 - 0
react/admin-panel/src/views/profile/collapse-box.js

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

+ 235 - 45
react/admin-panel/src/views/profile/profile.js

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

+ 1 - 6
react/admin-panel/src/views/route/route-edit.js

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

+ 8 - 8
react/admin-panel/src/views/settings.js

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

+ 1 - 1
react/shared/elements/data-table.js

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