import { useEffect, useMemo, useState } from 'preact/hooks'; import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact'; import { copyTextToClipboard } from '@/lib/clipboard'; import qrcode from 'qrcode-generator'; import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types'; import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n'; import ConfirmDialog from '@/components/ConfirmDialog'; import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel'; interface SettingsPageProps { profile: Profile; totpEnabled: boolean; lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30; sessionTimeoutAction: 'lock' | 'logout'; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onSavePasswordHint: (masterPasswordHint: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; onGetRecoveryCode: (masterPassword: string) => Promise; onGetApiKey: (masterPassword: string) => Promise; onRotateApiKey: (masterPassword: string) => Promise; onListAccountPasskeys: () => Promise; onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise; pendingAuthRequests: AuthRequest[]; pendingAuthRequestsLoading: boolean; onRefreshPendingAuthRequests: () => Promise; onApproveAuthRequest: (request: AuthRequest) => Promise; onDenyAuthRequest: (request: AuthRequest) => Promise; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void; onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void; } type MasterPasswordPromptAction = | 'recovery' | 'apiKey' | 'rotateApiKey' | 'createPasskey' | 'enablePasskeyDirectUnlock' | 'deletePasskey'; const LOCK_TIMEOUT_OPTIONS = [ { value: 1, labelKey: 'txt_timeout_1_minute' }, { value: 5, labelKey: 'txt_timeout_5_minutes' }, { value: 15, labelKey: 'txt_timeout_15_minutes' }, { value: 30, labelKey: 'txt_timeout_30_minutes' }, { value: 0, labelKey: 'txt_timeout_never' }, ] as const; function randomBase32Secret(length: number): string { const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'; let out = ''; const maxUnbiasedByte = Math.floor(256 / alphabet.length) * alphabet.length; while (out.length < length) { const random = crypto.getRandomValues(new Uint8Array(length)); for (const x of random) { if (x >= maxUnbiasedByte) continue; out += alphabet[x % alphabet.length]; if (out.length >= length) break; } } return out; } function buildOtpUri(email: string, secret: string): string { const issuer = 'NodeWarden'; return `otpauth://totp/${encodeURIComponent(`${issuer}:${email}`)}?secret=${encodeURIComponent(secret)}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30`; } function clearLegacyTotpSetupSecrets(): void { if (typeof window === 'undefined') return; const prefix = 'nodewarden.totp.secret.'; const keys: string[] = []; for (let index = 0; index < window.localStorage.length; index += 1) { const key = window.localStorage.key(index); if (key?.startsWith(prefix)) keys.push(key); } for (const key of keys) { window.localStorage.removeItem(key); } } function formatDateTime(value: string | null | undefined): string { if (!value) return t('txt_dash'); const date = new Date(value); if (Number.isNaN(date.getTime())) return t('txt_dash'); return date.toLocaleString(); } export default function SettingsPage(props: SettingsPageProps) { const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [newPassword2, setNewPassword2] = useState(''); const [passwordHint, setPasswordHint] = useState(props.profile.masterPasswordHint || ''); const [secret, setSecret] = useState(() => randomBase32Secret(32)); const [token, setToken] = useState(''); const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [recoveryCode, setRecoveryCode] = useState(''); const [apiKey, setApiKey] = useState(''); const [accountPasskeys, setAccountPasskeys] = useState([]); const [accountPasskeysLoading, setAccountPasskeysLoading] = useState(false); const [accountPasskeyName, setAccountPasskeyName] = useState(t('txt_account_passkey')); const [accountPasskeyDirectUnlock, setAccountPasskeyDirectUnlock] = useState(false); const [accountPasskeyPromptId, setAccountPasskeyPromptId] = useState(null); const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false); const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false); const [masterPasswordPrompt, setMasterPasswordPrompt] = useState(null); const [masterPasswordPromptValue, setMasterPasswordPromptValue] = useState(''); const [masterPasswordPromptSubmitting, setMasterPasswordPromptSubmitting] = useState(false); const [selectedLocale, setSelectedLocale] = useState(() => getLocale()); useEffect(() => { clearLegacyTotpSetupSecrets(); }, []); useEffect(() => { if (!props.totpEnabled) { setTotpLocked(false); return; } setTotpLocked(true); }, [props.totpEnabled]); useEffect(() => { setPasswordHint(props.profile.masterPasswordHint || ''); }, [props.profile.masterPasswordHint]); useEffect(() => { void refreshAccountPasskeys(); }, [props.profile.id]); const qrDataUrl = useMemo(() => { const qr = qrcode(0, 'M'); qr.addData(buildOtpUri(props.profile.email, secret)); qr.make(); // Keep a visible quiet zone so authenticator apps can scan reliably in both themes. const svg = qr.createSvgTag({ scalable: true, margin: 4 }); return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; }, [props.profile.email, secret]); async function enableTotp(): Promise { try { await props.onEnableTotp(secret, token); setTotpLocked(true); } catch { // Keep inputs editable after a failed attempt. } } async function refreshAccountPasskeys(): Promise { setAccountPasskeysLoading(true); try { setAccountPasskeys(await props.onListAccountPasskeys()); } catch (error) { props.onNotify?.('error', error instanceof Error ? error.message : t('txt_account_passkeys_load_failed')); } finally { setAccountPasskeysLoading(false); } } function openMasterPasswordPrompt(action: MasterPasswordPromptAction, credentialId?: string): void { setMasterPasswordPrompt(action); setAccountPasskeyPromptId(credentialId || null); setMasterPasswordPromptValue(''); } function closeMasterPasswordPrompt(): void { if (masterPasswordPromptSubmitting) return; setMasterPasswordPrompt(null); setAccountPasskeyPromptId(null); setMasterPasswordPromptValue(''); } async function submitMasterPasswordPrompt(): Promise { if (!masterPasswordPrompt || masterPasswordPromptSubmitting) return; const masterPassword = masterPasswordPromptValue; setMasterPasswordPromptSubmitting(true); try { if (masterPasswordPrompt === 'recovery') { const code = await props.onGetRecoveryCode(masterPassword); setRecoveryCode(code); props.onNotify?.('success', t('txt_recovery_code_loaded')); } else if (masterPasswordPrompt === 'apiKey') { const key = await props.onGetApiKey(masterPassword); setApiKey(key); setApiKeyDialogOpen(true); } else if (masterPasswordPrompt === 'rotateApiKey') { const key = await props.onRotateApiKey(masterPassword); setApiKey(key); setApiKeyDialogOpen(true); props.onNotify?.('success', t('txt_api_key_rotated')); } else if (masterPasswordPrompt === 'createPasskey') { const credential = await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock); if (credential) await refreshAccountPasskeys(); } else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') { if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found')); await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword); await refreshAccountPasskeys(); } else if (masterPasswordPrompt === 'deletePasskey') { if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found')); await props.onDeleteAccountPasskey(accountPasskeyPromptId, masterPassword); await refreshAccountPasskeys(); } setMasterPasswordPrompt(null); setAccountPasskeyPromptId(null); setMasterPasswordPromptValue(''); } catch (error) { props.onNotify?.('error', error instanceof Error ? error.message : t('txt_master_password_is_required_2')); } finally { setMasterPasswordPromptSubmitting(false); } } const masterPasswordPromptTitle = masterPasswordPrompt === 'recovery' ? t('txt_view_recovery_code') : masterPasswordPrompt === 'rotateApiKey' ? t('txt_rotate_api_key') : masterPasswordPrompt === 'createPasskey' ? t('txt_add_account_passkey') : masterPasswordPrompt === 'enablePasskeyDirectUnlock' ? t('txt_enable_passkey_direct_unlock') : masterPasswordPrompt === 'deletePasskey' ? t('txt_delete_account_passkey') : t('txt_view_api_key'); function accountPasskeyStatusText(credential: AccountPasskeyCredential): string { if (credential.prfStatus === 0) return t('txt_direct_unlock'); if (credential.prfStatus === 1) return t('txt_login_only'); return t('txt_prf_not_supported'); } async function changeLocale(next: Locale): Promise { if (next === getLocale()) return; setSelectedLocale(next); await setLocale(next); window.location.reload(); } return (

{t('txt_session_timeout')}

{t('txt_language')}

{t('txt_change_master_password')}

{t('txt_password_hint_optional')}

{t('txt_totp')}

{totpLocked && ( )}
TOTP QR

{t('txt_account_passkeys')}

{t('txt_account_passkey_mode')}
{accountPasskeyDirectUnlock ? t('txt_account_passkey_direct_unlock_help') : t('txt_account_passkey_login_only_help')}
{accountPasskeysLoading ? (
{t('txt_loading')}
) : accountPasskeys.length === 0 ? (
{t('txt_no_account_passkeys')}
) : ( accountPasskeys.map((credential) => (
{credential.name || t('txt_account_passkey')} {t('txt_created_value', { value: formatDateTime(credential.creationDate) })}
{accountPasskeyStatusText(credential)}
{credential.prfStatus === 1 && ( )}
)) )}

{t('txt_recovery_code')}

{t('txt_this_is_a_one_time_code_after_it_is_used_a_new_code_is_generated_automatically')}

{recoveryCode && (
{recoveryCode}
)}

{t('txt_api_key')}

{t('txt_api_key_dialog_intro')}

void submitMasterPasswordPrompt()} onCancel={closeMasterPasswordPrompt} > setApiKeyDialogOpen(false)} onCancel={() => setApiKeyDialogOpen(false)} >
{t('txt_warning')}
{t('txt_api_key_warning_body')}
{t('txt_oauth_client_credentials')}
{([ [t('txt_client_id'), `user.${props.profile.id}`], [t('txt_client_secret'), apiKey], [t('txt_scope'), 'api'], [t('txt_grant_type'), 'client_credentials'], ] as [string, string][]).map(([label, value]) => ( ))}
{ setRotateApiKeyConfirmOpen(false); openMasterPasswordPrompt('rotateApiKey'); }} onCancel={() => setRotateApiKeyConfirmOpen(false)} />
); }