mirror of
https://github.com/Frooodle/Stirling-PDF.git
synced 2026-04-22 23:08:53 +02:00
hardening (#5807)
Co-authored-by: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
118
frontend/src/core/components/shared/EditableSecretField.tsx
Normal file
118
frontend/src/core/components/shared/EditableSecretField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user