From e97ac343652afb4032b96a2df9a2b5d98f367cb1 Mon Sep 17 00:00:00 2001 From: Roman Date: Sat, 6 Apr 2024 19:09:12 +0200 Subject: [PATCH] Mail bugfix, gpg, profile frontend WIP --- Core/API/MailAPI.class.php | 26 +- Core/API/UserAPI.class.php | 12 +- Core/Localization/de_DE/account.php | 11 + Core/Localization/de_DE/general.php | 1 + Core/Localization/de_DE/settings.php | 12 - Core/Localization/en_US/account.php | 11 + Core/Localization/en_US/general.php | 1 + Core/Localization/en_US/settings.php | 12 - README.md | 1 - cli.php | 30 +- react/admin-panel/src/elements/button-bar.js | 9 + .../admin-panel/src/views/group/group-edit.js | 9 +- .../src/views/profile/collapse-box.js | 39 +++ .../admin-panel/src/views/profile/profile.js | 280 +++++++++++++++--- .../admin-panel/src/views/route/route-edit.js | 7 +- react/admin-panel/src/views/settings.js | 16 +- react/shared/elements/data-table.js | 2 +- 17 files changed, 377 insertions(+), 102 deletions(-) create mode 100644 react/admin-panel/src/elements/button-bar.js create mode 100644 react/admin-panel/src/views/profile/collapse-box.js diff --git a/Core/API/MailAPI.class.php b/Core/API/MailAPI.class.php index d89312b..da3a354 100644 --- a/Core/API/MailAPI.class.php +++ b/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; diff --git a/Core/API/UserAPI.class.php b/Core/API/UserAPI.class.php index e12e6d4..5b84257 100644 --- a/Core/API/UserAPI.class.php +++ b/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 { diff --git a/Core/Localization/de_DE/account.php b/Core/Localization/de_DE/account.php index 601b887..b377c25 100644 --- a/Core/Localization/de_DE/account.php +++ b/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", ]; \ No newline at end of file diff --git a/Core/Localization/de_DE/general.php b/Core/Localization/de_DE/general.php index f928b0c..150e549 100644 --- a/Core/Localization/de_DE/general.php +++ b/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", diff --git a/Core/Localization/de_DE/settings.php b/Core/Localization/de_DE/settings.php index ac4a21e..e29a520 100644 --- a/Core/Localization/de_DE/settings.php +++ b/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", diff --git a/Core/Localization/en_US/account.php b/Core/Localization/en_US/account.php index 8777a72..cc49424 100644 --- a/Core/Localization/en_US/account.php +++ b/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", ]; \ No newline at end of file diff --git a/Core/Localization/en_US/general.php b/Core/Localization/en_US/general.php index 4f4b76a..3559ddb 100644 --- a/Core/Localization/en_US/general.php +++ b/Core/Localization/en_US/general.php @@ -16,6 +16,7 @@ return [ "yes" => "Yes", "no" => "No", "create_new" => "Create", + "unchanged" => "Unchanged", # dialog / actions "action" => "Action", diff --git a/Core/Localization/en_US/settings.php b/Core/Localization/en_US/settings.php index b8f05d0..a7ef151 100644 --- a/Core/Localization/en_US/settings.php +++ b/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", diff --git a/README.md b/README.md index b06640e..ff73760 100644 --- a/README.md +++ b/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: diff --git a/cli.php b/cli.php index 1580c2c..dc97aed 100755 --- a/cli.php +++ b/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 [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 [options...]"); + _exit("Usage: cli.php mail [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"], diff --git a/react/admin-panel/src/elements/button-bar.js b/react/admin-panel/src/elements/button-bar.js new file mode 100644 index 0000000..77f4e6b --- /dev/null +++ b/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; \ No newline at end of file diff --git a/react/admin-panel/src/views/group/group-edit.js b/react/admin-panel/src/views/group/group-edit.js index 8f899c2..0327a7e 100644 --- a/react/admin-panel/src/views/group/group-edit.js +++ b/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); diff --git a/react/admin-panel/src/views/profile/collapse-box.js b/react/admin-panel/src/views/profile/collapse-box.js new file mode 100644 index 0000000..2b96438 --- /dev/null +++ b/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 +
+ { open ? : } + { icon } +
{title}
+
+ + {children} + +
+} \ No newline at end of file diff --git a/react/admin-panel/src/views/profile/profile.js b/react/admin-panel/src/views/profile/profile.js index 24a5fe2..1d44daa 100644 --- a/react/admin-panel/src/views/profile/profile.js +++ b/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 <>
@@ -79,7 +186,7 @@ export default function ProfileView(props) {
- + {L("account.username")} setProfile({...profile, name: e.target.value })} /> - - + + {L("account.full_name")} setProfile({...profile, fullName: e.target.value })} /> - -

{L("account.change_password")}

- - {L("account.old_password")} - - setChangePassword({...changePassword, old: e.target.value })} /> - - - - {L("account.new_password")} - - setChangePassword({...changePassword, new: e.target.value })} /> - - - - {L("account.confirm_password")} - - setChangePassword({...changePassword, confirm: e.target.value })} /> - - - + + + setOpenedTab(openedTab === "password" ? "" : "password")} + icon={}> + + {L("account.password_old")} + + setChangePassword({...changePassword, old: e.target.value })} /> + + + + {L("account.password_new")} + + setChangePassword({...changePassword, new: e.target.value })} /> + + + + {L("account.password_confirm")} + + setChangePassword({...changePassword, confirm: e.target.value })} /> + + + + + setOpenedTab(openedTab === "gpg" ? "" : "gpg")} + icon={}> + { + profile.gpgKey ? + + { profile.gpgKey.confirmed ? + : + + } + GPG-Fingerprint: navigator.clipboard.writeText(profile.gpgKey.fingerprint)}> + {profile.gpgKey.fingerprint} + + + + {L("account.password")} + + setGpgKeyPassword(e.target.value)} + placeholder={L("account.password")} + /> + + + + : + + + {L("account.gpg_key")} + setGpgKey(e.target.value)} + onDrop={e => { + let file = e.dataTransfer.files[0]; + getFileContents(file, (data) => { + setGpgKey(data); + }); + return false; + }}/> + + + + + + + } + + + setOpenedTab(openedTab === "2fa" ? "" : "2fa")} + icon={}> + test + + + + +
} \ No newline at end of file diff --git a/react/admin-panel/src/views/route/route-edit.js b/react/admin-panel/src/views/route/route-edit.js index 0bf7b48..91c4083 100644 --- a/react/admin-panel/src/views/route/route-edit.js +++ b/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": { diff --git a/react/admin-panel/src/views/settings.js b/react/admin-panel/src/views/settings.js index b98158c..101b1aa 100644 --- a/react/admin-panel/src/views/settings.js +++ b/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}
+ + {data.output} + + , 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) { onChangeValue(key_name, e.target.value)} /> diff --git a/react/shared/elements/data-table.js b/react/shared/elements/data-table.js index e78f1a5..7df6926 100644 --- a/react/shared/elements/data-table.js +++ b/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);