feat: add passkey-based two-factor authentication

This commit is contained in:
shuaiplus
2026-07-05 14:51:11 +08:00
parent f63b745d05
commit c019c93726
31 changed files with 1540 additions and 116 deletions
+37 -4
View File
@@ -58,6 +58,7 @@ import {
type PendingPasskeyPassword,
type PendingTotp,
} from '@/lib/app-auth';
import { assertTwoFactorPasskey } from '@/lib/account-passkeys';
import useAccountSecurityActions from '@/hooks/useAccountSecurityActions';
import useAdminActions from '@/hooks/useAdminActions';
import useBackupActions from '@/hooks/useBackupActions';
@@ -152,6 +153,8 @@ const SIGNALR_UPDATE_TYPE_AUTH_REQUEST = 15;
const SIGNALR_UPDATE_TYPE_AUTH_REQUEST_RESPONSE = 16;
const SIGNALR_UPDATE_TYPE_DEVICE_STATUS = 101;
const SIGNALR_UPDATE_TYPE_BACKUP_RESTORE_PROGRESS = 102;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
type ThemePreference = 'system' | 'light' | 'dark';
type LockTimeoutMinutes = 0 | 1 | 5 | 15 | 30;
@@ -654,19 +657,38 @@ export default function App() {
}
}
function handleSelectTotpProvider(providerType: number) {
if (totpSubmitting) return;
setPendingTotp((current) => {
if (!current || current.providerType === providerType) return current;
const canUseProvider = current.availableProviders.includes(providerType);
if (!canUseProvider) return current;
return {
...current,
providerType,
providerData: current.providerDataByType[providerType],
};
});
setTotpCode('');
}
async function handleTotpVerify() {
if (totpSubmitting) return;
if (!pendingTotp) return;
if (!totpCode.trim()) {
pushToast('error', pendingTotp.providerType === 3 ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
const isPasskeyTwoFactor = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN;
if (!isPasskeyTwoFactor && !totpCode.trim()) {
pushToast('error', pendingTotp.providerType === TWO_FACTOR_PROVIDER_YUBIKEY ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
return;
}
setTotpSubmitting(true);
try {
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
const token = isPasskeyTwoFactor
? await assertTwoFactorPasskey(pendingTotp.providerData)
: totpCode;
const login = await performTotpLogin(pendingTotp, token, rememberDevice);
await finalizeLogin(login);
} catch (error) {
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : t('txt_totp_verify_failed'));
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : isPasskeyTwoFactor ? t('txt_passkey_verification_failed') : t('txt_totp_verify_failed'));
} finally {
setTotpSubmitting(false);
}
@@ -952,11 +974,13 @@ export default function App() {
onCancelConfirm={() => {}}
pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode=""
rememberDevice={false}
onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
@@ -1957,6 +1981,7 @@ export default function App() {
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
passkey2faEnabled: !!twoFactorStatusQuery.data?.passkeyEnabled,
lockTimeoutMinutes,
sessionTimeoutAction,
authorizedDevices: authorizedDevicesQuery.data || [],
@@ -2014,6 +2039,10 @@ export default function App() {
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
onDisableYubiKey: accountSecurityActions.disableYubiKey,
onGetTwoFactorPasskeySettings: accountSecurityActions.getTwoFactorPasskeySettings,
onCreateTwoFactorPasskey: accountSecurityActions.createTwoFactorPasskey,
onDeleteTwoFactorPasskey: accountSecurityActions.deleteTwoFactorPasskey,
onDisableTwoFactorPasskeys: accountSecurityActions.disableTwoFactorPasskeys,
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
onGetApiKey: accountSecurityActions.getApiKey,
onRotateApiKey: accountSecurityActions.rotateApiKey,
@@ -2219,11 +2248,13 @@ export default function App() {
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={!!pendingTotp}
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
pendingTotpAvailableProviders={pendingTotp?.availableProviders ?? []}
totpCode={totpCode}
rememberDevice={rememberDevice}
onTotpCodeChange={setTotpCode}
onRememberDeviceChange={setRememberDevice}
onConfirmTotp={() => void handleTotpVerify()}
onSelectTotpProvider={handleSelectTotpProvider}
onCancelTotp={() => {
if (totpSubmitting) return;
setPendingTotp(null);
@@ -2279,11 +2310,13 @@ export default function App() {
onCancelConfirm={() => setConfirm(null)}
pendingTotpOpen={false}
pendingTotpProviderType={0}
pendingTotpAvailableProviders={[]}
totpCode=""
rememberDevice={false}
onTotpCodeChange={() => {}}
onRememberDeviceChange={() => {}}
onConfirmTotp={() => {}}
onSelectTotpProvider={() => {}}
onCancelTotp={() => {}}
onUseRecoveryCode={() => {}}
totpSubmitting={false}
+82 -9
View File
@@ -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}
+11 -1
View File
@@ -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}
-28
View File
@@ -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>
+162 -5
View File
@@ -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={(
+54 -1
View File
@@ -7,19 +7,24 @@ import {
deleteAuthorizedDevices,
deriveLoginHash,
deleteAccountPasskey as deleteAccountPasskeyApi,
deleteTwoFactorPasskey as deleteTwoFactorPasskeyApi,
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
disableTwoFactorPasskeys as disableTwoFactorPasskeysApi,
disableYubiKeyOtp,
getCurrentDeviceIdentifier,
getApiKey,
getAccountPasskeyAttestationOptions,
getAccountPasskeyUpdateAssertionOptions,
getTotpRecoveryCode,
getTwoFactorPasskeyChallenge,
getTwoFactorPasskeySettings as getTwoFactorPasskeySettingsApi,
getYubiKeyOtpSettings,
listAccountPasskeys,
rotateApiKey,
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
saveAccountPasskey,
saveTwoFactorPasskey,
saveYubiKeyOtpApiCredentials,
saveYubiKeyOtpSettings,
setTotp,
@@ -33,11 +38,12 @@ import {
buildAccountPasskeyPrfKeySet,
buildAccountPasskeyPrfKeySetFromPrfKey,
createAccountPasskeyCredential,
createTwoFactorPasskeyCredential,
} from '@/lib/account-passkeys';
import { t } from '@/lib/i18n';
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
import type { AuthedFetch } from '@/lib/api/shared';
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, YubiKeyOtpSettings } from '@/lib/types';
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, TwoFactorPasskeySettings, YubiKeyOtpSettings } from '@/lib/types';
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -257,6 +263,53 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
onNotify('success', t('txt_yubikey_disabled'));
},
async getTwoFactorPasskeySettings(masterPassword: string): Promise<TwoFactorPasskeySettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
return getTwoFactorPasskeySettingsApi(authedFetch, derived.hash);
},
async createTwoFactorPasskey(name: string, masterPassword: string): Promise<TwoFactorPasskeySettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const normalizedName = String(name || '').trim() || t('txt_passkey');
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const challenge = await getTwoFactorPasskeyChallenge(authedFetch, derived.hash);
const deviceResponse = await createTwoFactorPasskeyCredential(challenge);
const settings = await saveTwoFactorPasskey(authedFetch, {
name: normalizedName,
masterPasswordHash: derived.hash,
deviceResponse,
});
await refetchTwoFactorStatus();
onNotify('success', t('txt_two_step_passkey_added'));
return settings;
},
async deleteTwoFactorPasskey(id: number, masterPassword: string): Promise<TwoFactorPasskeySettings> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const settings = await deleteTwoFactorPasskeyApi(authedFetch, { id, masterPasswordHash: derived.hash });
await refetchTwoFactorStatus();
onNotify('success', t('txt_two_step_passkey_removed'));
return settings;
},
async disableTwoFactorPasskeys(masterPassword: string): Promise<void> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
if (!normalized) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
await disableTwoFactorPasskeysApi(authedFetch, derived.hash);
await refetchTwoFactorStatus();
onNotify('success', t('txt_two_step_passkeys_disabled'));
},
async getRecoveryCode(masterPassword: string): Promise<string> {
if (!profile) throw new Error(t('txt_profile_unavailable'));
const normalized = String(masterPassword || '');
+22
View File
@@ -340,6 +340,28 @@ export async function createAccountPasskeyCredential(
};
}
export async function createTwoFactorPasskeyCredential(options: unknown): Promise<Record<string, unknown>> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const credential = await navigator.credentials.create({ publicKey: cloneCreationOptions(options) });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_no_passkey_created'));
}
return attestationRequest(credential);
}
export async function assertTwoFactorPasskey(options: unknown): Promise<string> {
if (!window.PublicKeyCredential || !navigator.credentials) {
throw new Error(t('txt_passkey_browser_not_supported'));
}
const credential = await navigator.credentials.get({ publicKey: cloneRequestOptions(options) });
if (!(credential instanceof PublicKeyCredential)) {
throw new Error(t('txt_invalid_passkey_assertion_response'));
}
return JSON.stringify(assertionRequest(credential));
}
function parseRsaEncryptedUserKey(value: string): Uint8Array {
const text = String(value || '').trim();
const [type, payload] = text.split('.');
+96 -1
View File
@@ -7,6 +7,7 @@ import type {
SessionState,
TokenError,
TokenSuccess,
TwoFactorPasskeySettings,
YubiKeyOtpSettings,
} from '../types';
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
@@ -756,6 +757,99 @@ export async function disableYubiKeyOtp(
}
}
function normalizeTwoFactorPasskeySettings(raw: any): TwoFactorPasskeySettings {
const keys = Array.isArray(raw?.keys) ? raw.keys : Array.isArray(raw?.Keys) ? raw.Keys : [];
return {
enabled: !!(raw?.enabled ?? raw?.Enabled),
keys: keys
.map((item: any) => ({
id: Number(item?.id ?? item?.Id),
name: String(item?.name || item?.Name || ''),
migrated: !!(item?.migrated ?? item?.Migrated),
}))
.filter((item: { id: number }) => Number.isInteger(item.id) && item.id > 0),
};
}
export async function getTwoFactorPasskeySettings(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/get-webauthn', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_master_password_verify_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function getTwoFactorPasskeyChallenge(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<unknown> {
const resp = await authedFetch('/api/two-factor/get-webauthn-challenge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
}
return parseJson<unknown>(resp);
}
export async function saveTwoFactorPasskey(
authedFetch: AuthedFetch,
payload: { id?: number; name: string; masterPasswordHash: string; deviceResponse: unknown }
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/webauthn', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_passkey_setup_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function deleteTwoFactorPasskey(
authedFetch: AuthedFetch,
payload: { id: number; masterPasswordHash: string }
): Promise<TwoFactorPasskeySettings> {
const resp = await authedFetch('/api/two-factor/webauthn', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_delete_item_failed')));
}
return normalizeTwoFactorPasskeySettings(await parseJson<unknown>(resp));
}
export async function disableTwoFactorPasskeys(
authedFetch: AuthedFetch,
masterPasswordHash: string
): Promise<void> {
const resp = await authedFetch('/api/two-factor/disable', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ type: 7, masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_passkey_two_step_failed')));
}
}
export async function verifyMasterPassword(
authedFetch: AuthedFetch,
masterPasswordHash: string
@@ -913,7 +1007,7 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
return stamp;
}
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean }> {
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: boolean; passkeyEnabled: boolean }> {
const resp = await authedFetch('/api/two-factor');
if (!resp.ok) throw new Error('Failed to load two-factor status');
const body = (await parseJson<{ data?: unknown[]; Data?: unknown[] }>(resp)) || {};
@@ -926,6 +1020,7 @@ export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Prom
return {
totpEnabled: enabledTypes.has(0),
yubikeyEnabled: enabledTypes.has(3),
passkeyEnabled: enabledTypes.has(7),
};
}
+107 -20
View File
@@ -35,6 +35,9 @@ export interface PendingTotp {
masterKey: Uint8Array;
kdfIterations: number;
providerType: number;
providerData?: unknown;
availableProviders: number[];
providerDataByType: Record<number, unknown>;
}
export interface PendingPasskeyPassword {
@@ -73,27 +76,94 @@ export interface CompletedLogin {
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
const TWO_FACTOR_PROVIDER_WEBAUTHN = 7;
const SUPPORTED_TWO_FACTOR_PROVIDERS = [
TWO_FACTOR_PROVIDER_WEBAUTHN,
TWO_FACTOR_PROVIDER_YUBIKEY,
TWO_FACTOR_PROVIDER_AUTHENTICATOR,
] as const;
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
}
function resolvePendingTwoFactorProvider(providers: unknown): number {
type TwoFactorTokenError = {
TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
CustomResponse?: {
TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
};
error_description?: string;
error?: string;
};
function readTwoFactorProviders(error: TwoFactorTokenError): unknown {
return error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders ?? error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
}
function readTwoFactorProviderData(error: TwoFactorTokenError, providerType: number): unknown {
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (!providers2 || typeof providers2 !== 'object') return undefined;
const record = providers2 as Record<string, unknown>;
return record[String(providerType)] ?? (providerType === TWO_FACTOR_PROVIDER_WEBAUTHN ? record.WebAuthn : undefined);
}
function twoFactorProviderTypeFromValue(value: unknown): number | null {
const raw = value && typeof value === 'object'
? (value as Record<string, unknown>).Type ?? (value as Record<string, unknown>).type
: value;
const text = String(raw ?? '').trim();
if (!text) return null;
const normalized = text.toLowerCase();
const numeric = Number(text);
const provider = Number.isFinite(numeric)
? numeric
: normalized === 'webauthn'
? TWO_FACTOR_PROVIDER_WEBAUTHN
: normalized === 'yubikey' || normalized === 'yubikeyotp'
? TWO_FACTOR_PROVIDER_YUBIKEY
: normalized === 'authenticator' || normalized === 'totp'
? TWO_FACTOR_PROVIDER_AUTHENTICATOR
: Number.NaN;
return SUPPORTED_TWO_FACTOR_PROVIDERS.includes(provider as any) ? provider : null;
}
function sortTwoFactorProviders(providerTypes: number[]): number[] {
const unique = new Set(providerTypes);
return SUPPORTED_TWO_FACTOR_PROVIDERS.filter((provider) => unique.has(provider));
}
function readTwoFactorProviderTypes(providers: unknown): number[] {
const providerTypes: number[] = [];
if (Array.isArray(providers)) {
if (providers.some((provider: any) => Number(
provider && typeof provider === 'object' ? provider.Type ?? provider.type : provider
) === TWO_FACTOR_PROVIDER_YUBIKEY)) {
return TWO_FACTOR_PROVIDER_YUBIKEY;
for (const provider of providers) {
const providerType = twoFactorProviderTypeFromValue(provider);
if (providerType != null) providerTypes.push(providerType);
}
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
}
if (providers && typeof providers === 'object') {
const record = providers as Record<string, unknown>;
if (record[String(TWO_FACTOR_PROVIDER_YUBIKEY)] || record.YubiKey || record.Yubikey) {
return TWO_FACTOR_PROVIDER_YUBIKEY;
} else if (providers && typeof providers === 'object') {
for (const [key, value] of Object.entries(providers as Record<string, unknown>)) {
if (!value) continue;
const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) providerTypes.push(providerType);
}
}
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
return sortTwoFactorProviders(providerTypes);
}
function readTwoFactorProviderDataMap(error: TwoFactorTokenError): Record<number, unknown> {
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (!providers2 || typeof providers2 !== 'object') return {};
const out: Record<number, unknown> = {};
for (const [key, value] of Object.entries(providers2 as Record<string, unknown>)) {
const providerType = twoFactorProviderTypeFromValue(key);
if (providerType != null) out[providerType] = value;
}
return out;
}
function resolvePendingTwoFactorProvider(providers: unknown): number {
return readTwoFactorProviderTypes(providers)[0] ?? TWO_FACTOR_PROVIDER_AUTHENTICATOR;
}
export type PasswordLoginResult =
@@ -438,8 +508,12 @@ export async function performPasswordLogin(
};
}
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
const tokenError = token as TwoFactorTokenError;
const providers = readTwoFactorProviders(tokenError);
if (providers) {
const providerType = resolvePendingTwoFactorProvider(providers);
const availableProviders = readTwoFactorProviderTypes(providers);
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
return {
kind: 'totp',
pendingTotp: {
@@ -447,7 +521,10 @@ export async function performPasswordLogin(
passwordHash: derived.hash,
masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations,
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
providerType,
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
availableProviders: availableProviders.length ? availableProviders : [providerType],
providerDataByType,
},
};
}
@@ -528,7 +605,10 @@ export async function performTotpLogin(
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
}
const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
const fallback = pendingTotp.providerType === TWO_FACTOR_PROVIDER_WEBAUTHN
? t('txt_passkey_verification_failed')
: t('txt_totp_verify_failed');
throw new Error(translateServerError(tokenError.error_description || tokenError.error, fallback));
}
export async function performRecoverTwoFactorLogin(
@@ -608,7 +688,7 @@ export async function performUnlock(
return unlockOffline();
}
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
let token: TokenSuccess | TwoFactorTokenError;
try {
token = await loginWithPassword(normalizedEmail, derived.hash, {
useRememberToken: true,
@@ -630,8 +710,12 @@ export async function performUnlock(
};
}
const tokenError = token as { TwoFactorProviders?: unknown; error_description?: string; error?: string };
if (tokenError.TwoFactorProviders) {
const tokenError = token as TwoFactorTokenError;
const providers = readTwoFactorProviders(tokenError);
if (providers) {
const providerType = resolvePendingTwoFactorProvider(providers);
const availableProviders = readTwoFactorProviderTypes(providers);
const providerDataByType = readTwoFactorProviderDataMap(tokenError);
return {
kind: 'totp',
pendingTotp: {
@@ -639,7 +723,10 @@ export async function performUnlock(
passwordHash: derived.hash,
masterKey: derived.masterKey,
kdfIterations: derived.kdfIterations,
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
providerType,
providerData: providerDataByType[providerType] ?? readTwoFactorProviderData(tokenError, providerType),
availableProviders: availableProviders.length ? availableProviders : [providerType],
providerDataByType,
},
};
}
+11
View File
@@ -907,6 +907,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
adminLoading: false,
adminError: '',
totpEnabled: true,
passkey2faEnabled: false,
authorizedDevices: state.authorizedDevices,
authorizedDevicesLoading: false,
authorizedDevicesError: '',
@@ -1060,6 +1061,16 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onSavePasswordHint: readonly,
onEnableTotp: readonly,
onOpenDisableTotp: readonlyVoid,
onGetTwoFactorPasskeySettings: async () => ({ enabled: false, keys: [] }),
onCreateTwoFactorPasskey: async () => {
await readonly();
return { enabled: false, keys: [] };
},
onDeleteTwoFactorPasskey: async () => {
await readonly();
return { enabled: false, keys: [] };
},
onDisableTwoFactorPasskeys: readonly,
onGetRecoveryCode: readonlyString,
onGetApiKey: readonlyString,
onRotateApiKey: readonlyString,
+17
View File
@@ -806,6 +806,23 @@ const en: Record<string, string> = {
"txt_password_hint_too_long": "Password hint must be 120 characters or fewer",
"txt_passkey": "Passkey",
"txt_passkeys": "Passkeys",
"txt_register": "Register",
"txt_key_list": "Key list",
"txt_select_another_verification_method": "Select another verification method",
"txt_select_two_step_login_method": "Select two-step login method",
"txt_two_step_passkeys": "Passkey two-step login",
"txt_two_step_passkeys_help": "Manage passkeys used only for two-step login.",
"txt_two_step_passkey_name_placeholder": "Security key",
"txt_add_two_step_passkey": "Add passkey",
"txt_two_step_passkey_added": "Passkey two-step login updated",
"txt_two_step_passkey_removed": "Passkey removed",
"txt_two_step_passkeys_disabled": "Passkey two-step login disabled",
"txt_disable_passkey_two_step_failed": "Failed to disable passkey two-step login",
"txt_use_passkey_to_complete_two_step_verification": "Use your passkey to complete two-step verification.",
"txt_touch_your_passkey_when_prompted": "Continue and approve the browser passkey prompt.",
"txt_no_two_step_passkeys": "No two-step passkeys",
"txt_remove_last_passkey_hint": "Disable passkey two-step login to remove the last key.",
"txt_passkey_setup_failed": "Passkey setup failed",
"txt_passkey_created_at_value": "Created on {value}",
"txt_account_passkey": "Account passkey",
"txt_account_passkeys": "Account passkeys",
+17
View File
@@ -806,6 +806,23 @@ const es: Record<string, string> = {
"txt_password_hint_too_long": "La pista de contraseña debe tener 120 caracteres o menos",
"txt_passkey": "Clave de acceso",
"txt_passkeys": "Claves de acceso",
"txt_register": "Registrar",
"txt_key_list": "Lista de claves",
"txt_select_another_verification_method": "Seleccionar otro método de verificación",
"txt_select_two_step_login_method": "Seleccionar método de inicio de sesión en dos pasos",
"txt_two_step_passkeys": "Inicio de sesión en dos pasos con clave de acceso",
"txt_two_step_passkeys_help": "Administra claves de acceso usadas solo para el inicio de sesión en dos pasos.",
"txt_two_step_passkey_name_placeholder": "Llave de seguridad",
"txt_add_two_step_passkey": "Agregar clave de acceso",
"txt_two_step_passkey_added": "Inicio de sesión en dos pasos con clave de acceso actualizado",
"txt_two_step_passkey_removed": "Clave de acceso eliminada",
"txt_two_step_passkeys_disabled": "Inicio de sesión en dos pasos con clave de acceso desactivado",
"txt_disable_passkey_two_step_failed": "No se pudo desactivar el inicio de sesión en dos pasos con clave de acceso",
"txt_use_passkey_to_complete_two_step_verification": "Usa tu clave de acceso para completar la verificación en dos pasos.",
"txt_touch_your_passkey_when_prompted": "Continúa y aprueba la solicitud de clave de acceso del navegador.",
"txt_no_two_step_passkeys": "No hay claves de acceso en dos pasos",
"txt_remove_last_passkey_hint": "Desactiva el inicio de sesión en dos pasos con clave de acceso para eliminar la última clave.",
"txt_passkey_setup_failed": "Error al configurar la clave de acceso",
"txt_passkey_created_at_value": "Creado el {value}",
"txt_account_passkey": "Clave de acceso de cuenta",
"txt_account_passkeys": "Claves de acceso de cuenta",
+17
View File
@@ -806,6 +806,23 @@ const ru: Record<string, string> = {
"txt_password_hint_too_long": "Подсказка к паролю должна содержать не более 120 символов.",
"txt_passkey": "Ключ доступа",
"txt_passkeys": "Ключи доступа",
"txt_register": "Зарегистрировать",
"txt_key_list": "Список ключей",
"txt_select_another_verification_method": "Выбрать другой способ проверки",
"txt_select_two_step_login_method": "Выберите способ двухэтапного входа",
"txt_two_step_passkeys": "Двухэтапный вход с ключом доступа",
"txt_two_step_passkeys_help": "Управление ключами доступа, которые используются только для двухэтапного входа.",
"txt_two_step_passkey_name_placeholder": "Ключ безопасности",
"txt_add_two_step_passkey": "Добавить ключ доступа",
"txt_two_step_passkey_added": "Двухэтапный вход с ключом доступа обновлен",
"txt_two_step_passkey_removed": "Ключ доступа удален",
"txt_two_step_passkeys_disabled": "Двухэтапный вход с ключом доступа отключен",
"txt_disable_passkey_two_step_failed": "Не удалось отключить двухэтапный вход с ключом доступа",
"txt_use_passkey_to_complete_two_step_verification": "Используйте ключ доступа, чтобы завершить двухэтапную проверку.",
"txt_touch_your_passkey_when_prompted": "Продолжите и подтвердите запрос ключа доступа в браузере.",
"txt_no_two_step_passkeys": "Нет ключей доступа для двухэтапного входа",
"txt_remove_last_passkey_hint": "Отключите двухэтапный вход с ключом доступа, чтобы удалить последний ключ.",
"txt_passkey_setup_failed": "Не удалось настроить ключ доступа",
"txt_passkey_created_at_value": "Создано {value}",
"txt_account_passkey": "Ключ доступа аккаунта",
"txt_account_passkeys": "Ключи доступа аккаунта",
+17
View File
@@ -806,6 +806,23 @@ const zhCN: Record<string, string> = {
"txt_password_hint_too_long": "密码提示最多只能输入 120 个字符",
"txt_passkey": "通行密钥",
"txt_passkeys": "通行密钥",
"txt_register": "注册",
"txt_key_list": "密钥列表",
"txt_select_another_verification_method": "选择其他验证方式",
"txt_select_two_step_login_method": "选择验证方式",
"txt_two_step_passkeys": "通行密钥二步登录",
"txt_two_step_passkeys_help": "管理仅用于二步登录的通行密钥。",
"txt_two_step_passkey_name_placeholder": "安全密钥",
"txt_add_two_step_passkey": "添加通行密钥",
"txt_two_step_passkey_added": "通行密钥二步登录已更新",
"txt_two_step_passkey_removed": "通行密钥已移除",
"txt_two_step_passkeys_disabled": "通行密钥二步登录已禁用",
"txt_disable_passkey_two_step_failed": "禁用通行密钥二步登录失败",
"txt_use_passkey_to_complete_two_step_verification": "使用通行密钥完成二步验证。",
"txt_touch_your_passkey_when_prompted": "继续并在浏览器提示中批准通行密钥验证。",
"txt_no_two_step_passkeys": "暂无二步登录通行密钥",
"txt_remove_last_passkey_hint": "请禁用通行密钥二步登录来移除最后一把密钥。",
"txt_passkey_setup_failed": "通行密钥设置失败",
"txt_passkey_created_at_value": "创建于 {value}",
"txt_account_passkey": "账号通行密钥",
"txt_account_passkeys": "账号通行密钥",
+17
View File
@@ -806,6 +806,23 @@ const zhTW: Record<string, string> = {
"txt_password_hint_too_long": "密碼提示最多隻能輸入 120 個字符",
"txt_passkey": "通行密鑰",
"txt_passkeys": "通行密鑰",
"txt_register": "註冊",
"txt_key_list": "密鑰列表",
"txt_select_another_verification_method": "選擇其他驗證方式",
"txt_select_two_step_login_method": "選擇驗證方式",
"txt_two_step_passkeys": "通行密鑰兩步登入",
"txt_two_step_passkeys_help": "管理僅用於兩步登入的通行密鑰。",
"txt_two_step_passkey_name_placeholder": "安全密鑰",
"txt_add_two_step_passkey": "新增通行密鑰",
"txt_two_step_passkey_added": "通行密鑰兩步登入已更新",
"txt_two_step_passkey_removed": "通行密鑰已移除",
"txt_two_step_passkeys_disabled": "通行密鑰兩步登入已停用",
"txt_disable_passkey_two_step_failed": "停用通行密鑰兩步登入失敗",
"txt_use_passkey_to_complete_two_step_verification": "使用通行密鑰完成兩步驗證。",
"txt_touch_your_passkey_when_prompted": "繼續並在瀏覽器提示中批准通行密鑰驗證。",
"txt_no_two_step_passkeys": "暫無兩步登入通行密鑰",
"txt_remove_last_passkey_hint": "請停用通行密鑰兩步登入來移除最後一把密鑰。",
"txt_passkey_setup_failed": "通行密鑰設置失敗",
"txt_passkey_created_at_value": "創建於 {value}",
"txt_account_passkey": "賬號通行密鑰",
"txt_account_passkeys": "賬號通行密鑰",
+11
View File
@@ -350,6 +350,17 @@ export interface AccountPasskeyCredential {
revisionDate?: string;
}
export interface TwoFactorPasskeyCredential {
id: number;
name: string;
migrated?: boolean;
}
export interface TwoFactorPasskeySettings {
enabled: boolean;
keys: TwoFactorPasskeyCredential[];
}
export interface AuthRequest {
id: string;
publicKey: string;
+6 -13
View File
@@ -30,14 +30,11 @@
.not-found-page {
@apply relative grid min-h-full place-items-center overflow-hidden p-6 text-center;
background:
radial-gradient(circle at 50% 42%, rgba(28, 118, 255, 0.24), transparent 27rem),
radial-gradient(circle at 16% 84%, rgba(22, 163, 255, 0.10), transparent 22rem),
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
background: var(--surface);
}
.not-found-shell {
@apply relative z-20 grid w-full max-w-[620px] justify-items-center gap-5 px-4 py-7 text-center;
@apply relative z-20 grid w-full max-w-[560px] justify-items-center gap-6 px-4 py-7 text-center;
background: transparent;
border: 0;
box-shadow: none;
@@ -356,7 +353,7 @@
}
.not-found-logo {
@apply h-14 w-[70px] flex-shrink-0 object-contain;
@apply h-14 w-14 flex-shrink-0 object-contain;
filter: drop-shadow(0 8px 18px rgba(43, 102, 217, 0.22));
}
@@ -377,17 +374,16 @@
.not-found-copy {
@apply grid justify-items-center gap-3;
text-shadow: 0 2px 18px rgba(0, 0, 0, 0.38);
}
.not-found-shell h1 {
@apply m-0 text-3xl font-extrabold leading-tight;
color: #f8fbff;
color: var(--text);
}
.not-found-shell p {
@apply m-0 max-w-[420px] text-sm leading-relaxed;
color: rgba(220, 232, 251, 0.82);
color: var(--muted);
}
.not-found-action {
@@ -396,10 +392,7 @@
@media (max-width: 520px) {
.not-found-page {
background:
radial-gradient(circle at 50% 36%, rgba(28, 118, 255, 0.24), transparent 18rem),
radial-gradient(circle at 18% 82%, rgba(22, 163, 255, 0.10), transparent 16rem),
linear-gradient(180deg, #020b1a 0%, #061328 48%, #0a1730 100%);
background: var(--surface);
}
.not-found-shell {
+39 -1
View File
@@ -1352,7 +1352,7 @@
}
.settings-module-head {
@apply mb-[5px] flex items-center justify-between gap-3;
@apply mb-[10px] flex items-center justify-between gap-3;
}
.settings-module-head h3 {
@@ -1382,10 +1382,37 @@
accent-color: var(--primary);
}
.account-passkey-list,
.account-passkeys-list {
@apply mt-3 grid gap-2;
}
.two-factor-passkey-register-row {
@apply flex min-w-0 items-center gap-2;
}
.two-factor-passkey-register-row .input {
@apply min-w-0 flex-1;
}
.two-factor-passkey-register-row .btn {
@apply shrink-0;
min-width: 86px;
}
.two-factor-passkey-list-block {
@apply grid gap-2;
}
.settings-list-label {
@apply text-sm font-extrabold;
color: var(--muted);
}
.two-factor-passkey-danger-actions {
@apply justify-end pt-1;
}
.account-passkey-row {
@apply grid min-w-0 items-center gap-3 rounded-lg border p-3;
grid-template-columns: minmax(0, 1fr) auto auto;
@@ -1393,6 +1420,17 @@
background: var(--panel);
}
.two-factor-passkey-row {
grid-template-columns: 2rem minmax(0, 1fr) auto;
}
.account-passkey-index {
@apply inline-grid h-8 w-8 place-items-center rounded-lg text-sm font-extrabold;
border: 1px solid var(--line);
color: var(--muted);
background: color-mix(in srgb, var(--panel) 80%, var(--panel-2));
}
.account-passkey-main {
@apply grid min-w-0 gap-1;
}
+20 -1
View File
@@ -91,7 +91,7 @@
}
.dialog-extra {
@apply mt-2;
@apply mt-2 grid gap-2;
}
.dialog-divider {
@@ -99,6 +99,25 @@
background: var(--line);
}
.two-factor-method-switcher {
@apply grid gap-2;
}
.two-factor-method-list {
@apply grid gap-2 rounded-lg border p-2;
border-color: var(--line);
background: color-mix(in srgb, var(--panel) 92%, var(--surface));
}
.two-factor-method-label {
@apply px-1 text-left text-sm font-extrabold;
color: var(--muted);
}
.two-factor-method-option {
@apply min-h-11 justify-start text-base;
}
.import-summary-dialog {
@apply relative max-w-[520px] pt-4 text-left;
}
+18
View File
@@ -1143,6 +1143,15 @@
padding: 10px;
}
.two-factor-passkey-row {
grid-template-columns: 2rem minmax(0, 1fr);
}
.two-factor-passkey-row .btn {
grid-column: 2;
justify-self: end;
}
.account-passkey-status {
justify-self: flex-start;
}
@@ -1152,6 +1161,15 @@
width: 100%;
}
.two-factor-passkey-register-row {
flex-direction: column;
align-items: stretch;
}
.two-factor-passkey-register-row .btn {
width: 100%;
}
.settings-module .totp-grid,
.settings-submodule .totp-grid {
gap: 8px;