mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-06 15:10:13 +00:00
feat: add passkey-based two-factor authentication
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
import ToastHost from '@/components/ToastHost';
|
||||
import { t } from '@/lib/i18n';
|
||||
@@ -22,11 +23,13 @@ interface AppGlobalOverlaysProps {
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
pendingTotpProviderType?: number;
|
||||
pendingTotpAvailableProviders?: number[];
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
onRememberDeviceChange: (checked: boolean) => void;
|
||||
onConfirmTotp: () => void;
|
||||
onSelectTotpProvider: (providerType: number) => void;
|
||||
onCancelTotp: () => void;
|
||||
onUseRecoveryCode: () => void;
|
||||
totpSubmitting: boolean;
|
||||
@@ -38,8 +41,40 @@ interface AppGlobalOverlaysProps {
|
||||
disableTotpSubmitting: boolean;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
|
||||
const TWO_FACTOR_PROVIDER_ORDER = [
|
||||
TWO_FACTOR_PROVIDER_WEBAUTHN,
|
||||
TWO_FACTOR_PROVIDER_YUBIKEY,
|
||||
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
|
||||
] as const;
|
||||
|
||||
function uniqueSupportedProviders(providerTypes: number[] | undefined): number[] {
|
||||
const available = new Set(providerTypes || []);
|
||||
return TWO_FACTOR_PROVIDER_ORDER.filter((provider) => available.has(provider));
|
||||
}
|
||||
|
||||
function twoFactorProviderLabel(providerType: number): string {
|
||||
if (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN) return t('txt_passkey');
|
||||
if (providerType === TWO_FACTOR_PROVIDER_YUBIKEY) return t('txt_otp_from_yubikey');
|
||||
return t('txt_authenticator_app');
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === 3;
|
||||
const [methodChooserOpen, setMethodChooserOpen] = useState(false);
|
||||
const availableProviders = useMemo(
|
||||
() => uniqueSupportedProviders(props.pendingTotpAvailableProviders),
|
||||
[props.pendingTotpAvailableProviders]
|
||||
);
|
||||
const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType);
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY;
|
||||
const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN;
|
||||
|
||||
useEffect(() => {
|
||||
setMethodChooserOpen(false);
|
||||
}, [props.pendingTotpOpen, props.pendingTotpProviderType]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -57,10 +92,11 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : t('txt_two_step_verification')}
|
||||
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : t('txt_password_is_already_verified')}
|
||||
title={isYubiKeyOtp ? `${t('txt_two_step_verification')} YubiKey` : isWebAuthn ? `${t('txt_two_step_verification')} ${t('txt_passkey')}` : t('txt_two_step_verification')}
|
||||
message={isYubiKeyOtp ? t('txt_press_yubikey_to_authenticate') : isWebAuthn ? t('txt_use_passkey_to_complete_two_step_verification') : t('txt_password_is_already_verified')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
showIcon={false}
|
||||
confirmDisabled={props.totpSubmitting}
|
||||
cancelDisabled={props.totpSubmitting}
|
||||
@@ -69,16 +105,52 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
afterActions={(
|
||||
<div className="dialog-extra">
|
||||
<div className="dialog-divider" />
|
||||
{alternateProviders.length > 0 && (
|
||||
<div className="two-factor-method-switcher">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary dialog-btn"
|
||||
disabled={props.totpSubmitting}
|
||||
aria-expanded={methodChooserOpen}
|
||||
onClick={() => setMethodChooserOpen((open) => !open)}
|
||||
>
|
||||
{t('txt_select_another_verification_method')}
|
||||
</button>
|
||||
{methodChooserOpen && (
|
||||
<div className="two-factor-method-list" role="list" aria-label={t('txt_select_two_step_login_method')}>
|
||||
<div className="two-factor-method-label">{t('txt_select_two_step_login_method')}</div>
|
||||
{alternateProviders.map((providerType) => (
|
||||
<button
|
||||
key={providerType}
|
||||
type="button"
|
||||
className="btn btn-secondary two-factor-method-option"
|
||||
disabled={props.totpSubmitting}
|
||||
onClick={() => {
|
||||
setMethodChooserOpen(false);
|
||||
props.onSelectTotpProvider(providerType);
|
||||
}}
|
||||
>
|
||||
{twoFactorProviderLabel(providerType)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={props.totpSubmitting} onClick={props.onUseRecoveryCode}>
|
||||
{t('txt_use_recovery_code')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
||||
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
{isWebAuthn ? (
|
||||
<p className="muted-inline settings-field-note">{t('txt_touch_your_passkey_when_prompted')}</p>
|
||||
) : (
|
||||
<label className="field">
|
||||
<span>{isYubiKeyOtp ? t('txt_otp_from_yubikey') : t('txt_totp_code')}</span>
|
||||
<input className="input" type={isYubiKeyOtp ? 'password' : 'text'} value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
)}
|
||||
<label className="check-line check-line-compact">
|
||||
<input type="checkbox" checked={props.rememberDevice} onChange={(e) => props.onRememberDeviceChange((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_trust_this_device_for_30_days')}</span>
|
||||
@@ -90,7 +162,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
title={t('txt_disable_totp')}
|
||||
message={t('txt_enter_master_password_to_disable_two_step_verification')}
|
||||
confirmText={t('txt_disable_totp')}
|
||||
cancelText={t('txt_cancel')}
|
||||
hideCancel
|
||||
closeButton
|
||||
danger
|
||||
showIcon={false}
|
||||
confirmDisabled={props.disableTotpSubmitting}
|
||||
|
||||
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
|
||||
import type { AuditLogFilters } from '@/lib/api/admin';
|
||||
import type { CiphersImportPayload } from '@/lib/api/vault';
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, TwoFactorPasskeySettings, VaultDraft, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
@@ -56,6 +56,7 @@ export interface AppMainRoutesProps {
|
||||
adminError: string;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
passkey2faEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
@@ -118,6 +119,10 @@ export interface AppMainRoutesProps {
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -276,6 +281,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
yubikeyEnabled={props.yubikeyEnabled}
|
||||
passkey2faEnabled={props.passkey2faEnabled}
|
||||
themePreference={props.themePreference}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
@@ -290,6 +296,10 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
||||
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
||||
onDisableYubiKey={props.onDisableYubiKey}
|
||||
onGetTwoFactorPasskeySettings={props.onGetTwoFactorPasskeySettings}
|
||||
onCreateTwoFactorPasskey={props.onCreateTwoFactorPasskey}
|
||||
onDeleteTwoFactorPasskey={props.onDeleteTwoFactorPasskey}
|
||||
onDisableTwoFactorPasskeys={props.onDisableTwoFactorPasskeys}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
|
||||
@@ -8,41 +8,13 @@ interface NotFoundPageProps {
|
||||
}
|
||||
|
||||
export default function NotFoundPage(props: NotFoundPageProps) {
|
||||
const starBoxes = [1, 2, 3, 4];
|
||||
const stars = [1, 2, 3, 4, 5, 6, 7];
|
||||
|
||||
return (
|
||||
<main className="not-found-page">
|
||||
<div className="not-found-space" aria-hidden="true">
|
||||
{starBoxes.map((box) => (
|
||||
<div key={box} className={`not-found-star-box not-found-star-box-${box}`}>
|
||||
{stars.map((star) => (
|
||||
<span key={star} className={`not-found-star not-found-star-position-${star}`} />
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<section className="not-found-shell" aria-labelledby="not-found-title">
|
||||
<div className="not-found-brand">
|
||||
<img src="/nodewarden-logo.svg" alt="NodeWarden logo" className="not-found-logo" />
|
||||
<span className="not-found-wordmark" aria-label="NodeWarden" role="img" />
|
||||
</div>
|
||||
|
||||
<div className="not-found-astro-stage" aria-hidden="true">
|
||||
<div className="not-found-astronaut">
|
||||
<div className="not-found-astro-head" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-left" />
|
||||
<div className="not-found-astro-arm not-found-astro-arm-right" />
|
||||
<div className="not-found-astro-body">
|
||||
<div className="not-found-astro-panel" />
|
||||
</div>
|
||||
<div className="not-found-astro-leg not-found-astro-leg-left" />
|
||||
<div className="not-found-astro-leg not-found-astro-leg-right" />
|
||||
<div className="not-found-astro-pack" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="not-found-copy">
|
||||
<div className="not-found-code">404</div>
|
||||
<h1 id="not-found-title">{props.title || t('txt_page_not_found')}</h1>
|
||||
|
||||
@@ -2,7 +2,7 @@ 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, Profile, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, Profile, TwoFactorPasskeyCredential, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
@@ -10,6 +10,7 @@ interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
passkey2faEnabled: boolean;
|
||||
themePreference: ThemePreference;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
@@ -24,6 +25,10 @@ interface SettingsPageProps {
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetTwoFactorPasskeySettings: (masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onCreateTwoFactorPasskey: (name: string, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDeleteTwoFactorPasskey: (id: number, masterPassword: string) => Promise<TwoFactorPasskeySettings>;
|
||||
onDisableTwoFactorPasskeys: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -47,6 +52,7 @@ type MasterPasswordPromptAction =
|
||||
| 'rotateApiKey'
|
||||
| 'manageTotp'
|
||||
| 'manageYubiKey'
|
||||
| 'managePasskey2fa'
|
||||
| 'createPasskey'
|
||||
| 'enablePasskeyDirectUnlock'
|
||||
| 'deletePasskey';
|
||||
@@ -143,6 +149,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
||||
const [yubiKeyConfigOpen, setYubiKeyConfigOpen] = useState(false);
|
||||
const [yubiKeySubmitting, setYubiKeySubmitting] = useState(false);
|
||||
const [twoFactorPasskeyEnabled, setTwoFactorPasskeyEnabled] = useState(props.passkey2faEnabled);
|
||||
const [twoFactorPasskeys, setTwoFactorPasskeys] = useState<TwoFactorPasskeyCredential[]>([]);
|
||||
const [twoFactorPasskeyDialogOpen, setTwoFactorPasskeyDialogOpen] = useState(false);
|
||||
const [twoFactorPasskeyMasterPassword, setTwoFactorPasskeyMasterPassword] = useState('');
|
||||
const [twoFactorPasskeyName, setTwoFactorPasskeyName] = useState(t('txt_passkey'));
|
||||
const [twoFactorPasskeySubmitting, setTwoFactorPasskeySubmitting] = useState(false);
|
||||
const [twoFactorStatusRefreshing, setTwoFactorStatusRefreshing] = useState(false);
|
||||
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
|
||||
const [totpManagePassword, setTotpManagePassword] = useState('');
|
||||
@@ -172,6 +184,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setYubiKeyEnabled(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
|
||||
}, [props.yubikeyEnabled, props.profile.yubikeyEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
setTwoFactorPasskeyEnabled(props.passkey2faEnabled);
|
||||
}, [props.passkey2faEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAccountPasskeys();
|
||||
}, [props.profile.id]);
|
||||
@@ -250,6 +266,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
applyYubiKeySettings(settings);
|
||||
setYubiKeyConfigOpen(false);
|
||||
setYubiKeyDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'managePasskey2fa') {
|
||||
const settings = await props.onGetTwoFactorPasskeySettings(masterPassword);
|
||||
setTwoFactorPasskeyMasterPassword(masterPassword);
|
||||
applyTwoFactorPasskeySettings(settings);
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
setTwoFactorPasskeyDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'createPasskey') {
|
||||
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
||||
setCreatePasskeyMasterPassword(masterPassword);
|
||||
@@ -284,6 +306,8 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
? t('txt_totp')
|
||||
: masterPasswordPrompt === 'manageYubiKey'
|
||||
? 'YubiKey'
|
||||
: masterPasswordPrompt === 'managePasskey2fa'
|
||||
? t('txt_two_step_passkeys')
|
||||
: masterPasswordPrompt === 'createPasskey'
|
||||
? t('txt_add_account_passkey')
|
||||
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
||||
@@ -327,7 +351,6 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyNfc(false);
|
||||
setYubiKeyEnabled(false);
|
||||
setYubiKeyYubicoConfigured(false);
|
||||
setYubiKeyYubicoClientId('');
|
||||
setYubiKeyYubicoSecretKey('');
|
||||
@@ -402,6 +425,60 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
}
|
||||
}
|
||||
|
||||
function applyTwoFactorPasskeySettings(settings: TwoFactorPasskeySettings): void {
|
||||
setTwoFactorPasskeyEnabled(settings.enabled);
|
||||
setTwoFactorPasskeys(settings.keys);
|
||||
}
|
||||
|
||||
function closeTwoFactorPasskeyDialog(): void {
|
||||
if (twoFactorPasskeySubmitting) return;
|
||||
setTwoFactorPasskeyDialogOpen(false);
|
||||
setTwoFactorPasskeyMasterPassword('');
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
}
|
||||
|
||||
async function createTwoFactorPasskeyDialog(): Promise<void> {
|
||||
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword) return;
|
||||
setTwoFactorPasskeySubmitting(true);
|
||||
try {
|
||||
const settings = await props.onCreateTwoFactorPasskey(twoFactorPasskeyName, twoFactorPasskeyMasterPassword);
|
||||
applyTwoFactorPasskeySettings(settings);
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_passkey_setup_failed'));
|
||||
} finally {
|
||||
setTwoFactorPasskeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTwoFactorPasskeyDialog(id: number): Promise<void> {
|
||||
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword || twoFactorPasskeys.length < 2) return;
|
||||
setTwoFactorPasskeySubmitting(true);
|
||||
try {
|
||||
applyTwoFactorPasskeySettings(await props.onDeleteTwoFactorPasskey(id, twoFactorPasskeyMasterPassword));
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_delete_item_failed'));
|
||||
} finally {
|
||||
setTwoFactorPasskeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableTwoFactorPasskeysDialog(): Promise<void> {
|
||||
if (twoFactorPasskeySubmitting || !twoFactorPasskeyMasterPassword || !twoFactorPasskeyEnabled) return;
|
||||
setTwoFactorPasskeySubmitting(true);
|
||||
try {
|
||||
await props.onDisableTwoFactorPasskeys(twoFactorPasskeyMasterPassword);
|
||||
applyTwoFactorPasskeySettings({ enabled: false, keys: [] });
|
||||
setTwoFactorPasskeyDialogOpen(false);
|
||||
setTwoFactorPasskeyMasterPassword('');
|
||||
setTwoFactorPasskeyName(t('txt_passkey'));
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_disable_passkey_two_step_failed'));
|
||||
} finally {
|
||||
setTwoFactorPasskeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTwoFactorStatus(): Promise<void> {
|
||||
if (twoFactorStatusRefreshing) return;
|
||||
setTwoFactorStatusRefreshing(true);
|
||||
@@ -726,10 +803,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
<KeyRound size={28} />
|
||||
</div>
|
||||
<div className="two-step-provider-copy">
|
||||
<strong>{t('txt_passkeys')}</strong>
|
||||
<div className="two-step-provider-title">
|
||||
<strong>{t('txt_passkeys')}</strong>
|
||||
{twoFactorPasskeyEnabled && <span className="two-step-enabled-badge">{t('txt_enabled')}</span>}
|
||||
</div>
|
||||
<span>{t('txt_passkey_provider_help')}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('managePasskey2fa')}>
|
||||
{t('txt_manage')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -1028,13 +1108,90 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
)}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={twoFactorPasskeyDialogOpen}
|
||||
title={t('txt_two_step_passkeys')}
|
||||
message={t('txt_two_step_passkeys_help')}
|
||||
hideConfirm
|
||||
hideCancel
|
||||
closeButton
|
||||
cancelDisabled={twoFactorPasskeySubmitting}
|
||||
onConfirm={() => {}}
|
||||
onCancel={closeTwoFactorPasskeyDialog}
|
||||
>
|
||||
<div className="settings-vertical-fields">
|
||||
<div className="field">
|
||||
<label htmlFor="two-factor-passkey-name">{t('txt_passkey_name')}</label>
|
||||
<div className="two-factor-passkey-register-row">
|
||||
<input
|
||||
id="two-factor-passkey-name"
|
||||
className="input"
|
||||
maxLength={128}
|
||||
value={twoFactorPasskeyName}
|
||||
placeholder={t('txt_two_step_passkey_name_placeholder')}
|
||||
onInput={(e) => setTwoFactorPasskeyName((e.currentTarget as HTMLInputElement).value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
disabled={twoFactorPasskeySubmitting}
|
||||
onClick={() => void createTwoFactorPasskeyDialog()}
|
||||
>
|
||||
<KeyRound size={14} className="btn-icon" />
|
||||
{t('txt_register')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="two-factor-passkey-list-block">
|
||||
<div className="settings-list-label">{t('txt_key_list')}</div>
|
||||
{twoFactorPasskeys.length > 0 ? (
|
||||
<div className="account-passkey-list">
|
||||
{twoFactorPasskeys.map((credential, index) => (
|
||||
<div key={credential.id} className="account-passkey-row two-factor-passkey-row">
|
||||
<span className="account-passkey-index">{index + 1}</span>
|
||||
<div className="account-passkey-main">
|
||||
<strong>{credential.name || t('txt_dash')}</strong>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small"
|
||||
disabled={twoFactorPasskeySubmitting || twoFactorPasskeys.length < 2}
|
||||
title={twoFactorPasskeys.length < 2 ? t('txt_remove_last_passkey_hint') : t('txt_delete')}
|
||||
onClick={() => void deleteTwoFactorPasskeyDialog(credential.id)}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
{t('txt_delete')}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted-inline settings-field-note">{t('txt_no_two_step_passkeys')}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="actions two-factor-passkey-danger-actions">
|
||||
{twoFactorPasskeyEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger"
|
||||
disabled={twoFactorPasskeySubmitting}
|
||||
onClick={() => void disableTwoFactorPasskeysDialog()}
|
||||
>
|
||||
{t('txt_disable_all_keys')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={recoveryCodeDialogOpen}
|
||||
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
|
||||
message={t('txt_your_two_step_recovery_code')}
|
||||
hideConfirm
|
||||
hideCancel
|
||||
closeButton
|
||||
cancelText={t('txt_close')}
|
||||
onConfirm={() => {}}
|
||||
onCancel={() => setRecoveryCodeDialogOpen(false)}
|
||||
afterActions={(
|
||||
|
||||
Reference in New Issue
Block a user