Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
Anthony Stirling
2026-03-02 13:56:39 +00:00
committed by GitHub
parent 8b25db37ad
commit 012bd1af92
21 changed files with 407 additions and 66 deletions

View File

@@ -0,0 +1,118 @@
import { useState, useRef, useEffect } from 'react';
import { PasswordInput, Group, ActionIcon, Tooltip, TextInput } from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
interface EditableSecretFieldProps {
label?: string;
description?: string;
value: string;
onChange: (value: string) => void;
placeholder?: string;
disabled?: boolean;
error?: string;
}
/**
* Component for editing sensitive fields (passwords, API keys, secrets).
*
* UX:
* - Normal password input in all scenarios EXCEPT when value is masked (********)
* - When backend returns masked value (********): Shows read-only display + Edit button
* - Click Edit to change the masked value
*/
export default function EditableSecretField({
label,
description,
value,
onChange,
placeholder = 'Enter value',
disabled = false,
error,
}: EditableSecretFieldProps) {
const { t } = useTranslation();
const [isEditing, setIsEditing] = useState(false);
const [tempValue, setTempValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const isMasked = value === '********';
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
}
}, [isEditing]);
const handleEdit = () => {
setTempValue('');
setIsEditing(true);
};
const handleCancel = () => {
setTempValue('');
setIsEditing(false);
};
const handleSave = () => {
if (tempValue.trim() !== '') {
onChange(tempValue);
}
setTempValue('');
setIsEditing(false);
};
return (
<div>
{label && <label style={{ display: 'block', marginBottom: 4, fontWeight: 500, fontSize: '0.875rem' }}>{label}</label>}
{description && <p style={{ margin: '4px 0 12px 0', fontSize: '0.75rem', color: '#666' }}>{description}</p>}
{isMasked && !isEditing ? (
// Masked value from backend: show display + Edit button
<Group gap="xs" align="flex-end">
<TextInput
value="••••••••"
disabled
style={{ flex: 1 }}
readOnly
/>
<Tooltip label={t('editSecret')} withArrow>
<ActionIcon
variant="light"
onClick={handleEdit}
disabled={disabled}
title="Edit"
aria-label="Edit secret value"
>
<LocalIcon icon="edit" width="1rem" height="1rem" />
</ActionIcon>
</Tooltip>
</Group>
) : isEditing ? (
// Edit mode: normal password input
<PasswordInput
ref={inputRef}
value={tempValue}
onChange={(e) => setTempValue(e.currentTarget.value)}
placeholder={placeholder}
disabled={disabled}
error={error}
autoComplete="new-password"
onBlur={handleSave}
onKeyDown={(e) => {
if (e.key === 'Escape') handleCancel();
}}
/>
) : (
// Normal password input: empty or user typing
<PasswordInput
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
placeholder={placeholder}
disabled={disabled}
error={error}
autoComplete="new-password"
/>
)}
</div>
);
}

View File

@@ -9,12 +9,12 @@ import {
TextInput,
Textarea,
Switch,
PasswordInput,
NumberInput,
TagsInput,
} from '@mantine/core';
import { useTranslation } from 'react-i18next';
import LocalIcon from '@app/components/shared/LocalIcon';
import EditableSecretField from '@app/components/shared/EditableSecretField';
import { Provider, ProviderField } from '@app/components/shared/config/configSections/providerDefinitions';
interface ProviderCardProps {
@@ -94,13 +94,13 @@ export default function ProviderCard({
case 'password':
return (
<PasswordInput
<EditableSecretField
key={field.key}
label={field.label}
description={field.description}
placeholder={field.placeholder}
value={value}
onChange={(e) => handleFieldChange(field.key, e.target.value)}
onChange={(newValue) => handleFieldChange(field.key, newValue)}
disabled={disabled}
/>
);

View File

@@ -10,7 +10,6 @@ import {
Loader,
Group,
TextInput,
PasswordInput,
Select,
Badge,
Table,
@@ -29,6 +28,7 @@ import { useAdminSettings } from "@app/hooks/useAdminSettings";
import PendingBadge from "@app/components/shared/config/PendingBadge";
import { useLoginRequired } from "@app/hooks/useLoginRequired";
import LoginRequiredBanner from "@app/components/shared/config/LoginRequiredBanner";
import EditableSecretField from "@app/components/shared/EditableSecretField";
import apiClient from "@app/services/apiClient";
import LocalIcon from "@app/components/shared/LocalIcon";
import databaseManagementService, { DatabaseBackupFile } from "@app/services/databaseManagementService";
@@ -515,17 +515,15 @@ export default function AdminDatabaseSection() {
</div>
<div>
<PasswordInput
label={
<Group gap="xs">
<span>{t("admin.settings.database.password.label", "Password")}</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
}
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: "0.875rem" }}>{t("admin.settings.database.password.label", "Password")}</span>
<PendingBadge show={isFieldPending("password")} />
</Group>
<EditableSecretField
description={t("admin.settings.database.password.description", "Database authentication password")}
value={settings?.password || ""}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
placeholder="••••••••"
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter database password"
disabled={!loginEnabled}
/>
</div>

View File

@@ -1,12 +1,13 @@
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, PasswordInput, Anchor } from '@mantine/core';
import { TextInput, NumberInput, Switch, Button, Stack, Paper, Text, Loader, Group, Anchor } from '@mantine/core';
import { alert } from '@app/components/toast';
import RestartConfirmationModal from '@app/components/shared/config/RestartConfirmationModal';
import { useRestartServer } from '@app/components/shared/config/useRestartServer';
import { useAdminSettings } from '@app/hooks/useAdminSettings';
import PendingBadge from '@app/components/shared/config/PendingBadge';
import EditableSecretField from '@app/components/shared/EditableSecretField';
import apiClient from '@app/services/apiClient';
interface MailSettingsData {
@@ -174,16 +175,15 @@ export default function AdminMailSection() {
</div>
<div>
<PasswordInput
label={
<Group gap="xs">
<span>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
}
<Group gap="xs" align="center" mb={4}>
<span style={{ fontWeight: 500, fontSize: '0.875rem' }}>{t('admin.settings.mail.password.label', 'SMTP Password')}</span>
<PendingBadge show={isFieldPending('password')} />
</Group>
<EditableSecretField
description={t('admin.settings.mail.password.description', 'SMTP authentication password')}
value={settings.password || ''}
onChange={(e) => setSettings({ ...settings, password: e.target.value })}
onChange={(value) => setSettings({ ...settings, password: value })}
placeholder="Enter SMTP password"
/>
</div>

View File

@@ -18,11 +18,29 @@ export default function ApiKeys() {
const copy = async (text: string, tag: string) => {
try {
await navigator.clipboard.writeText(text);
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
// Try modern Clipboard API first (requires HTTPS)
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text);
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
} else {
// Fallback for HTTP: use old execCommand method
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
if (document.execCommand('copy')) {
setCopied(tag);
setTimeout(() => setCopied(null), 1600);
}
document.body.removeChild(textarea);
}
} catch (e) {
console.error(e);
console.error('Failed to copy:', e);
}
};