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

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

@ -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",
];

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

@ -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",

@ -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",
];

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

@ -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",

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

30
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"],

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

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

@ -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": {

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

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