mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-05 06:50:10 +00:00
feat: Add YubiKey OTP support and management features
- Implemented YubiKey OTP settings management in useAccountSecurityActions hook. - Added API functions for retrieving, saving, and bootstrapping YubiKey OTP credentials. - Enhanced authentication flow to support multiple two-factor providers, including YubiKey. - Updated localization files to include new YubiKey-related strings in English, Spanish, Russian, and Chinese. - Introduced new styles for YubiKey management UI components. - Created utility functions for YubiKey OTP validation and credential handling.
This commit is contained in:
+21
-9
@@ -20,7 +20,7 @@ import {
|
||||
loadProfileSnapshot,
|
||||
saveProfileSnapshot,
|
||||
revokeCurrentSession,
|
||||
getTotpStatus,
|
||||
getTwoFactorProviderStatus,
|
||||
getVaultRevisionDate,
|
||||
saveSession,
|
||||
stripProfileSecrets,
|
||||
@@ -658,7 +658,7 @@ export default function App() {
|
||||
if (totpSubmitting) return;
|
||||
if (!pendingTotp) return;
|
||||
if (!totpCode.trim()) {
|
||||
pushToast('error', t('txt_please_input_totp_code'));
|
||||
pushToast('error', pendingTotp.providerType === 3 ? t('txt_please_input_yubikey_otp') : t('txt_please_input_totp_code'));
|
||||
return;
|
||||
}
|
||||
setTotpSubmitting(true);
|
||||
@@ -666,7 +666,7 @@ export default function App() {
|
||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||
await finalizeLogin(login);
|
||||
} catch (error) {
|
||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||
pushToast('error', error instanceof Error ? error.message : pendingTotp.providerType === 3 ? t('txt_yubikey_verify_failed') : t('txt_totp_verify_failed'));
|
||||
} finally {
|
||||
setTotpSubmitting(false);
|
||||
}
|
||||
@@ -951,6 +951,7 @@ export default function App() {
|
||||
confirm={null}
|
||||
onCancelConfirm={() => {}}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
@@ -1081,9 +1082,9 @@ export default function App() {
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && isAdmin && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
const totpStatusQuery = useQuery({
|
||||
queryKey: ['totp-status', vaultCacheKey || session?.email],
|
||||
queryFn: () => getTotpStatus(authedFetch),
|
||||
const twoFactorStatusQuery = useQuery({
|
||||
queryKey: ['two-factor-status', vaultCacheKey || session?.email],
|
||||
queryFn: () => getTwoFactorProviderStatus(authedFetch),
|
||||
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
|
||||
staleTime: 30_000,
|
||||
});
|
||||
@@ -1816,7 +1817,7 @@ export default function App() {
|
||||
onNotify: pushToast,
|
||||
onProfileUpdated: setProfile,
|
||||
onSetConfirm: setConfirm,
|
||||
refetchTotpStatus: totpStatusQuery.refetch,
|
||||
refetchTwoFactorStatus: twoFactorStatusQuery.refetch,
|
||||
refetchAuthorizedDevices: authorizedDevicesQuery.refetch,
|
||||
});
|
||||
const adminActions = useAdminActions({
|
||||
@@ -1954,7 +1955,8 @@ export default function App() {
|
||||
invites: invitesQuery.data || [],
|
||||
adminLoading: (usersQuery.isFetching && !usersQuery.data) || (invitesQuery.isFetching && !invitesQuery.data),
|
||||
adminError: usersQuery.isError || invitesQuery.isError ? t('txt_load_admin_data_failed') : '',
|
||||
totpEnabled: !!totpStatusQuery.data?.enabled,
|
||||
totpEnabled: !!twoFactorStatusQuery.data?.totpEnabled,
|
||||
yubikeyEnabled: !!twoFactorStatusQuery.data?.yubikeyEnabled,
|
||||
lockTimeoutMinutes,
|
||||
sessionTimeoutAction,
|
||||
authorizedDevices: authorizedDevicesQuery.data || [],
|
||||
@@ -2004,9 +2006,14 @@ export default function App() {
|
||||
onSavePasswordHint: accountSecurityActions.savePasswordHint,
|
||||
onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
|
||||
await accountSecurityActions.enableTotp(secret, token, masterPassword);
|
||||
await totpStatusQuery.refetch();
|
||||
await twoFactorStatusQuery.refetch();
|
||||
},
|
||||
onOpenDisableTotp: () => setDisableTotpOpen(true),
|
||||
onGetYubiKeySettings: accountSecurityActions.getYubiKeySettings,
|
||||
onSaveYubiKeySettings: accountSecurityActions.saveYubiKeySettings,
|
||||
onSaveYubiKeyApiCredentials: accountSecurityActions.saveYubiKeyApiCredentials,
|
||||
onBootstrapYubiKeyApiCredentials: accountSecurityActions.bootstrapYubiKeyApiCredentials,
|
||||
onDisableYubiKey: accountSecurityActions.disableYubiKey,
|
||||
onGetRecoveryCode: accountSecurityActions.getRecoveryCode,
|
||||
onGetApiKey: accountSecurityActions.getApiKey,
|
||||
onRotateApiKey: accountSecurityActions.rotateApiKey,
|
||||
@@ -2014,6 +2021,9 @@ export default function App() {
|
||||
onCreateAccountPasskey: accountSecurityActions.createAccountPasskey,
|
||||
onEnableAccountPasskeyDirectUnlock: accountSecurityActions.enableAccountPasskeyDirectUnlock,
|
||||
onDeleteAccountPasskey: accountSecurityActions.deleteAccountPasskey,
|
||||
onRefreshTwoFactorStatus: async () => {
|
||||
await twoFactorStatusQuery.refetch();
|
||||
},
|
||||
pendingAuthRequests,
|
||||
pendingAuthRequestsLoading: pendingAuthRequestsQuery.isLoading,
|
||||
pendingAuthRequestsRefreshing: pendingAuthRequestsQuery.isFetching && !pendingAuthRequestsQuery.isLoading,
|
||||
@@ -2208,6 +2218,7 @@ export default function App() {
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={!!pendingTotp}
|
||||
pendingTotpProviderType={pendingTotp?.providerType ?? 0}
|
||||
totpCode={totpCode}
|
||||
rememberDevice={rememberDevice}
|
||||
onTotpCodeChange={setTotpCode}
|
||||
@@ -2267,6 +2278,7 @@ export default function App() {
|
||||
confirm={confirm}
|
||||
onCancelConfirm={() => setConfirm(null)}
|
||||
pendingTotpOpen={false}
|
||||
pendingTotpProviderType={0}
|
||||
totpCode=""
|
||||
rememberDevice={false}
|
||||
onTotpCodeChange={() => {}}
|
||||
|
||||
@@ -21,6 +21,7 @@ interface AppGlobalOverlaysProps {
|
||||
confirm: AppConfirmState | null;
|
||||
onCancelConfirm: () => void;
|
||||
pendingTotpOpen: boolean;
|
||||
pendingTotpProviderType?: number;
|
||||
totpCode: string;
|
||||
rememberDevice: boolean;
|
||||
onTotpCodeChange: (value: string) => void;
|
||||
@@ -38,6 +39,7 @@ interface AppGlobalOverlaysProps {
|
||||
}
|
||||
|
||||
export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
const isYubiKeyOtp = props.pendingTotpProviderType === 3;
|
||||
return (
|
||||
<>
|
||||
<ConfirmDialog
|
||||
@@ -55,8 +57,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
|
||||
<ConfirmDialog
|
||||
open={props.pendingTotpOpen}
|
||||
title={t('txt_two_step_verification')}
|
||||
message={t('txt_password_is_already_verified')}
|
||||
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')}
|
||||
confirmText={t('txt_verify')}
|
||||
cancelText={t('txt_cancel')}
|
||||
showIcon={false}
|
||||
@@ -74,8 +76,8 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
|
||||
)}
|
||||
>
|
||||
<label className="field">
|
||||
<span>{t('txt_totp_code')}</span>
|
||||
<input className="input" value={props.totpCode} autoComplete="one-time-code" onInput={(e) => props.onTotpCodeChange((e.currentTarget as HTMLInputElement).value)} />
|
||||
<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)} />
|
||||
|
||||
@@ -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 } from '@/lib/types';
|
||||
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 { ExportRequest } from '@/lib/export-formats';
|
||||
|
||||
const VaultPage = lazy(() => import('@/components/VaultPage'));
|
||||
@@ -55,6 +55,7 @@ export interface AppMainRoutesProps {
|
||||
adminLoading: boolean;
|
||||
adminError: string;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
authorizedDevices: AuthorizedDevice[];
|
||||
@@ -112,6 +113,11 @@ export interface AppMainRoutesProps {
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -119,6 +125,7 @@ export interface AppMainRoutesProps {
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
onRefreshTwoFactorStatus: () => Promise<void>;
|
||||
pendingAuthRequests: AuthRequest[];
|
||||
pendingAuthRequestsLoading: boolean;
|
||||
pendingAuthRequestsRefreshing: boolean;
|
||||
@@ -268,6 +275,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
<SettingsPage
|
||||
profile={props.profile}
|
||||
totpEnabled={props.totpEnabled}
|
||||
yubikeyEnabled={props.yubikeyEnabled}
|
||||
themePreference={props.themePreference}
|
||||
lockTimeoutMinutes={props.lockTimeoutMinutes}
|
||||
sessionTimeoutAction={props.sessionTimeoutAction}
|
||||
@@ -277,6 +285,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onSavePasswordHint={props.onSavePasswordHint}
|
||||
onEnableTotp={props.onEnableTotp}
|
||||
onOpenDisableTotp={props.onOpenDisableTotp}
|
||||
onGetYubiKeySettings={props.onGetYubiKeySettings}
|
||||
onSaveYubiKeySettings={props.onSaveYubiKeySettings}
|
||||
onSaveYubiKeyApiCredentials={props.onSaveYubiKeyApiCredentials}
|
||||
onBootstrapYubiKeyApiCredentials={props.onBootstrapYubiKeyApiCredentials}
|
||||
onDisableYubiKey={props.onDisableYubiKey}
|
||||
onGetRecoveryCode={props.onGetRecoveryCode}
|
||||
onGetApiKey={props.onGetApiKey}
|
||||
onRotateApiKey={props.onRotateApiKey}
|
||||
@@ -284,6 +297,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
|
||||
onCreateAccountPasskey={props.onCreateAccountPasskey}
|
||||
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
|
||||
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
|
||||
onRefreshTwoFactorStatus={props.onRefreshTwoFactorStatus}
|
||||
onLockTimeoutChange={props.onLockTimeoutChange}
|
||||
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
|
||||
onNotify={props.onNotify}
|
||||
|
||||
@@ -7,7 +7,7 @@ import { t } from '@/lib/i18n';
|
||||
interface ConfirmDialogProps {
|
||||
open: boolean;
|
||||
title: string;
|
||||
message: string;
|
||||
message?: string;
|
||||
variant?: 'default' | 'warning';
|
||||
showIcon?: boolean;
|
||||
confirmText?: string;
|
||||
@@ -90,6 +90,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
const dialogId = useMemo(() => `confirm-dialog-${++dialogIdCounter}`, []);
|
||||
const titleId = `${dialogId}-title`;
|
||||
const messageId = `${dialogId}-message`;
|
||||
const hasMessage = !!props.message;
|
||||
const canDismiss = !props.cancelDisabled && !closing;
|
||||
|
||||
useEffect(() => {
|
||||
@@ -193,7 +194,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={messageId}
|
||||
aria-describedby={hasMessage ? messageId : undefined}
|
||||
tabIndex={-1}
|
||||
onKeyDown={handleDialogKeyDown}
|
||||
onSubmit={(e) => {
|
||||
@@ -228,7 +229,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
|
||||
</button>
|
||||
)}
|
||||
<h3 id={titleId} className="dialog-title">{props.title}</h3>
|
||||
<div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>
|
||||
{hasMessage && <div id={messageId} className={`dialog-message ${props.variant === 'warning' ? 'warning' : ''}`}>{props.message}</div>}
|
||||
{props.children}
|
||||
{!props.hideConfirm && (
|
||||
<button
|
||||
|
||||
@@ -2,13 +2,14 @@ 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 } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, Profile, YubiKeyOtpSettings } from '@/lib/types';
|
||||
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
|
||||
import ConfirmDialog from '@/components/ConfirmDialog';
|
||||
|
||||
interface SettingsPageProps {
|
||||
profile: Profile;
|
||||
totpEnabled: boolean;
|
||||
yubikeyEnabled: boolean;
|
||||
themePreference: ThemePreference;
|
||||
lockTimeoutMinutes: 0 | 1 | 5 | 15 | 30;
|
||||
sessionTimeoutAction: 'lock' | 'logout';
|
||||
@@ -18,6 +19,11 @@ interface SettingsPageProps {
|
||||
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetYubiKeySettings: (masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeySettings: (keys: string[], nfc: boolean, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onSaveYubiKeyApiCredentials: (clientId: string, secretKey: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onBootstrapYubiKeyApiCredentials: (otp: string, masterPassword: string) => Promise<YubiKeyOtpSettings>;
|
||||
onDisableYubiKey: (masterPassword: string) => Promise<void>;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onGetApiKey: (masterPassword: string) => Promise<string>;
|
||||
onRotateApiKey: (masterPassword: string) => Promise<string>;
|
||||
@@ -25,6 +31,7 @@ interface SettingsPageProps {
|
||||
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
|
||||
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
|
||||
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
|
||||
onRefreshTwoFactorStatus: () => Promise<void>;
|
||||
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
|
||||
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
|
||||
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
@@ -39,6 +46,7 @@ type MasterPasswordPromptAction =
|
||||
| 'apiKey'
|
||||
| 'rotateApiKey'
|
||||
| 'manageTotp'
|
||||
| 'manageYubiKey'
|
||||
| 'createPasskey'
|
||||
| 'enablePasskeyDirectUnlock'
|
||||
| 'deletePasskey';
|
||||
@@ -51,6 +59,18 @@ const LOCK_TIMEOUT_OPTIONS = [
|
||||
{ value: 0, labelKey: 'txt_timeout_never' },
|
||||
] as const;
|
||||
|
||||
const EMPTY_YUBIKEY_KEYS: [string, string, string, string, string] = ['', '', '', '', ''];
|
||||
|
||||
function formatStoredYubiKey(value: string): string {
|
||||
if (!value) return '';
|
||||
if (value.length >= 44) return value;
|
||||
return `${value}${'•'.repeat(44 - value.length)}`;
|
||||
}
|
||||
|
||||
function normalizeYubiKeyFieldValue(value: string): string {
|
||||
return value.replace(/\s+/g, '').toLowerCase();
|
||||
}
|
||||
|
||||
function randomBase32Secret(length: number): string {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
let out = '';
|
||||
@@ -111,6 +131,19 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
const [rotateApiKeyConfirmOpen, setRotateApiKeyConfirmOpen] = useState(false);
|
||||
const [apiKeyDialogOpen, setApiKeyDialogOpen] = useState(false);
|
||||
const [totpManageDialogOpen, setTotpManageDialogOpen] = useState(false);
|
||||
const [yubiKeyDialogOpen, setYubiKeyDialogOpen] = useState(false);
|
||||
const [yubiKeyMasterPassword, setYubiKeyMasterPassword] = useState('');
|
||||
const [yubiKeyEnabled, setYubiKeyEnabled] = useState(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
|
||||
const [yubiKeyKeys, setYubiKeyKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
|
||||
const [yubiKeyStoredKeys, setYubiKeyStoredKeys] = useState<[string, string, string, string, string]>(EMPTY_YUBIKEY_KEYS);
|
||||
const [yubiKeyNfc, setYubiKeyNfc] = useState(false);
|
||||
const [yubiKeyYubicoConfigured, setYubiKeyYubicoConfigured] = useState(false);
|
||||
const [yubiKeyYubicoClientId, setYubiKeyYubicoClientId] = useState('');
|
||||
const [yubiKeyYubicoSecretKey, setYubiKeyYubicoSecretKey] = useState('');
|
||||
const [yubiKeyBootstrapOtp, setYubiKeyBootstrapOtp] = useState('');
|
||||
const [yubiKeyConfigOpen, setYubiKeyConfigOpen] = useState(false);
|
||||
const [yubiKeySubmitting, setYubiKeySubmitting] = useState(false);
|
||||
const [twoFactorStatusRefreshing, setTwoFactorStatusRefreshing] = useState(false);
|
||||
const [recoveryCodeDialogOpen, setRecoveryCodeDialogOpen] = useState(false);
|
||||
const [totpManagePassword, setTotpManagePassword] = useState('');
|
||||
const [masterPasswordPrompt, setMasterPasswordPrompt] = useState<MasterPasswordPromptAction | null>(null);
|
||||
@@ -135,6 +168,10 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setPasswordHint(props.profile.masterPasswordHint || '');
|
||||
}, [props.profile.masterPasswordHint]);
|
||||
|
||||
useEffect(() => {
|
||||
setYubiKeyEnabled(props.yubikeyEnabled || !!props.profile.yubikeyEnabled);
|
||||
}, [props.yubikeyEnabled, props.profile.yubikeyEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
void refreshAccountPasskeys();
|
||||
}, [props.profile.id]);
|
||||
@@ -207,6 +244,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
||||
setTotpManagePassword(masterPassword);
|
||||
setTotpManageDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'manageYubiKey') {
|
||||
const settings = await props.onGetYubiKeySettings(masterPassword);
|
||||
setYubiKeyMasterPassword(masterPassword);
|
||||
applyYubiKeySettings(settings);
|
||||
setYubiKeyConfigOpen(false);
|
||||
setYubiKeyDialogOpen(true);
|
||||
} else if (masterPasswordPrompt === 'createPasskey') {
|
||||
await props.onVerifyMasterPassword(props.profile.email, masterPassword);
|
||||
setCreatePasskeyMasterPassword(masterPassword);
|
||||
@@ -239,7 +282,9 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
? t('txt_rotate_api_key')
|
||||
: masterPasswordPrompt === 'manageTotp'
|
||||
? t('txt_totp')
|
||||
: masterPasswordPrompt === 'createPasskey'
|
||||
: masterPasswordPrompt === 'manageYubiKey'
|
||||
? 'YubiKey'
|
||||
: masterPasswordPrompt === 'createPasskey'
|
||||
? t('txt_add_account_passkey')
|
||||
: masterPasswordPrompt === 'enablePasskeyDirectUnlock'
|
||||
? t('txt_enable_passkey_direct_unlock')
|
||||
@@ -265,6 +310,110 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setTotpManagePassword('');
|
||||
}
|
||||
|
||||
function applyYubiKeySettings(settings: YubiKeyOtpSettings): void {
|
||||
setYubiKeyEnabled(settings.enabled);
|
||||
setYubiKeyKeys(settings.keys);
|
||||
setYubiKeyStoredKeys(settings.keys);
|
||||
setYubiKeyNfc(settings.nfc);
|
||||
setYubiKeyYubicoConfigured(settings.yubicoConfigured);
|
||||
setYubiKeyYubicoClientId(settings.yubicoClientId);
|
||||
setYubiKeyYubicoSecretKey(settings.yubicoSecretKey);
|
||||
}
|
||||
|
||||
function closeYubiKeyDialog(): void {
|
||||
if (yubiKeySubmitting) return;
|
||||
setYubiKeyDialogOpen(false);
|
||||
setYubiKeyMasterPassword('');
|
||||
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyNfc(false);
|
||||
setYubiKeyEnabled(false);
|
||||
setYubiKeyYubicoConfigured(false);
|
||||
setYubiKeyYubicoClientId('');
|
||||
setYubiKeyYubicoSecretKey('');
|
||||
setYubiKeyBootstrapOtp('');
|
||||
setYubiKeyConfigOpen(false);
|
||||
}
|
||||
|
||||
function updateYubiKey(index: number, value: string): void {
|
||||
setYubiKeyKeys((current) => {
|
||||
const next = [...current] as [string, string, string, string, string];
|
||||
next[index] = value;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
async function saveYubiKeyDialog(): Promise<void> {
|
||||
if (yubiKeySubmitting) return;
|
||||
setYubiKeySubmitting(true);
|
||||
try {
|
||||
const settings = await props.onSaveYubiKeySettings(yubiKeyKeys.map((value) => value.trim()), yubiKeyNfc, yubiKeyMasterPassword);
|
||||
applyYubiKeySettings(settings);
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_yubikey_update_failed'));
|
||||
} finally {
|
||||
setYubiKeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function bootstrapYubiKeyConfigDialog(): Promise<void> {
|
||||
if (yubiKeySubmitting || !yubiKeyBootstrapOtp.trim()) return;
|
||||
const bootstrapOtp = yubiKeyBootstrapOtp.trim().toLowerCase();
|
||||
setYubiKeySubmitting(true);
|
||||
try {
|
||||
const settings = await props.onBootstrapYubiKeyApiCredentials(bootstrapOtp, yubiKeyMasterPassword);
|
||||
applyYubiKeySettings(settings);
|
||||
setYubiKeyKeys(settings.keys);
|
||||
setYubiKeyBootstrapOtp('');
|
||||
setYubiKeyConfigOpen(false);
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_yubikey_auto_config_failed'));
|
||||
} finally {
|
||||
setYubiKeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveYubiKeyConfigDialog(): Promise<void> {
|
||||
if (yubiKeySubmitting || !yubiKeyYubicoClientId.trim()) return;
|
||||
setYubiKeySubmitting(true);
|
||||
try {
|
||||
const settings = await props.onSaveYubiKeyApiCredentials(yubiKeyYubicoClientId.trim(), yubiKeyYubicoSecretKey.trim(), yubiKeyMasterPassword);
|
||||
applyYubiKeySettings(settings);
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_yubikey_config_update_failed'));
|
||||
} finally {
|
||||
setYubiKeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function disableYubiKeyDialog(): Promise<void> {
|
||||
if (yubiKeySubmitting || !yubiKeyMasterPassword) return;
|
||||
setYubiKeySubmitting(true);
|
||||
try {
|
||||
await props.onDisableYubiKey(yubiKeyMasterPassword);
|
||||
setYubiKeyEnabled(false);
|
||||
setYubiKeyKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyStoredKeys(EMPTY_YUBIKEY_KEYS);
|
||||
setYubiKeyNfc(false);
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_disable_yubikey_failed'));
|
||||
} finally {
|
||||
setYubiKeySubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshTwoFactorStatus(): Promise<void> {
|
||||
if (twoFactorStatusRefreshing) return;
|
||||
setTwoFactorStatusRefreshing(true);
|
||||
try {
|
||||
await props.onRefreshTwoFactorStatus();
|
||||
} catch (error) {
|
||||
props.onNotify?.('error', error instanceof Error ? error.message : t('txt_load_failed'));
|
||||
} finally {
|
||||
setTwoFactorStatusRefreshing(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function enableTotpFromManageDialog(): Promise<void> {
|
||||
if (totpLocked) return;
|
||||
if (!secret.trim() || !token.trim()) {
|
||||
@@ -543,7 +692,18 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</section>
|
||||
|
||||
<section className="settings-submodule two-step-providers-module">
|
||||
<h3>{t('txt_providers')}</h3>
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_providers')}</h3>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary small"
|
||||
disabled={twoFactorStatusRefreshing}
|
||||
onClick={() => void refreshTwoFactorStatus()}
|
||||
>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
{t('txt_refresh_status')}
|
||||
</button>
|
||||
</div>
|
||||
<div className="two-step-provider-list">
|
||||
<div className="two-step-provider-row">
|
||||
<div className="two-step-provider-icon">
|
||||
@@ -577,10 +737,13 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
<div className="two-step-provider-row">
|
||||
<div className="two-step-provider-icon two-step-provider-yubico">yubico</div>
|
||||
<div className="two-step-provider-copy">
|
||||
<strong>{t('txt_yubico_otp_security_key')}</strong>
|
||||
<div className="two-step-provider-title">
|
||||
<strong>{t('txt_yubico_otp_security_key')}</strong>
|
||||
{yubiKeyEnabled && <span className="two-step-enabled-badge">{t('txt_enabled')}</span>}
|
||||
</div>
|
||||
<span>{t('txt_yubico_otp_security_key_help')}</span>
|
||||
</div>
|
||||
<button type="button" className="btn btn-secondary" disabled>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => openMasterPasswordPrompt('manageYubiKey')}>
|
||||
{t('txt_manage')}
|
||||
</button>
|
||||
</div>
|
||||
@@ -711,6 +874,160 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</div>
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={yubiKeyDialogOpen}
|
||||
title={`${t('txt_two_step_login')} YubiKey`}
|
||||
message={!yubiKeyYubicoConfigured ? '' : yubiKeyEnabled ? t('txt_yubikey_enabled') : t('txt_disabled')}
|
||||
hideConfirm
|
||||
hideCancel
|
||||
closeButton
|
||||
onConfirm={() => {
|
||||
if (yubiKeySubmitting) return;
|
||||
if (yubiKeyYubicoConfigured) {
|
||||
void saveYubiKeyDialog();
|
||||
} else {
|
||||
void bootstrapYubiKeyConfigDialog();
|
||||
}
|
||||
}}
|
||||
onCancel={closeYubiKeyDialog}
|
||||
afterActions={(
|
||||
<>
|
||||
{yubiKeyYubicoConfigured && (
|
||||
<button type="button" className="btn btn-primary dialog-btn" disabled={yubiKeySubmitting} onClick={() => void saveYubiKeyDialog()}>
|
||||
{t('txt_save')}
|
||||
</button>
|
||||
)}
|
||||
{yubiKeyEnabled && (
|
||||
<button type="button" className="btn btn-secondary dialog-btn" disabled={yubiKeySubmitting} onClick={() => void disableYubiKeyDialog()}>
|
||||
{t('txt_disable_all_keys')}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
>
|
||||
<div className="yubikey-manage-dialog-body">
|
||||
{!yubiKeyYubicoConfigured && (
|
||||
<section className="settings-submodule yubikey-config-panel">
|
||||
<h3>{t('txt_yubikey_config_required')}</h3>
|
||||
<p className="muted-inline settings-field-note">{t('txt_yubikey_config_required_help')}</p>
|
||||
<label className="field">
|
||||
<span>{t('txt_otp_from_yubikey')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
inputMode="verbatim"
|
||||
spellcheck={false}
|
||||
value={yubiKeyBootstrapOtp}
|
||||
onInput={(e) => setYubiKeyBootstrapOtp(normalizeYubiKeyFieldValue((e.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
</label>
|
||||
<button type="button" className="btn btn-primary" disabled={yubiKeySubmitting || !yubiKeyBootstrapOtp.trim()} onClick={() => void bootstrapYubiKeyConfigDialog()}>
|
||||
{t('txt_yubikey_auto_configure')}
|
||||
</button>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{yubiKeyYubicoConfigured && (
|
||||
<>
|
||||
<section className="settings-submodule yubikey-config-panel">
|
||||
<div className="settings-module-head">
|
||||
<h3>{t('txt_yubikey_validation_credentials')}</h3>
|
||||
<button type="button" className="btn btn-secondary small" onClick={() => setYubiKeyConfigOpen((open) => !open)}>
|
||||
{yubiKeyConfigOpen ? t('txt_hide') : t('txt_view')}
|
||||
</button>
|
||||
</div>
|
||||
{yubiKeyConfigOpen && (
|
||||
<div className="settings-vertical-fields">
|
||||
<label className="field">
|
||||
<span>Client ID</span>
|
||||
<input className="input" value={yubiKeyYubicoClientId} onInput={(e) => setYubiKeyYubicoClientId((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Secret key</span>
|
||||
<input className="input" value={yubiKeyYubicoSecretKey} onInput={(e) => setYubiKeyYubicoSecretKey((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>{t('txt_otp_from_yubikey')}</span>
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
inputMode="verbatim"
|
||||
spellcheck={false}
|
||||
value={yubiKeyBootstrapOtp}
|
||||
onInput={(e) => setYubiKeyBootstrapOtp(normalizeYubiKeyFieldValue((e.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
<div className="field-help">{t('txt_yubikey_reconfigure_help')}</div>
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-secondary" disabled={yubiKeySubmitting || !yubiKeyYubicoClientId.trim()} onClick={() => void saveYubiKeyConfigDialog()}>
|
||||
{t('txt_save')}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={yubiKeySubmitting || !yubiKeyBootstrapOtp.trim()} onClick={() => void bootstrapYubiKeyConfigDialog()}>
|
||||
{t('txt_yubikey_auto_configure_again')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<ol className="settings-plain-steps">
|
||||
<li>{t('txt_yubikey_plug_in')}</li>
|
||||
<li>{t('txt_yubikey_select_empty_field')}</li>
|
||||
<li>{t('txt_yubikey_touch_button')}</li>
|
||||
</ol>
|
||||
<div className="settings-vertical-fields">
|
||||
{yubiKeyKeys.map((keyValue, index) => (
|
||||
<label className="field" key={index}>
|
||||
<span>{t('txt_yubikey_x').replace('{index}', String(index + 1))}</span>
|
||||
<div className="yubikey-input-row">
|
||||
{yubiKeyStoredKeys[index] && keyValue === yubiKeyStoredKeys[index] ? (
|
||||
<span className="yubikey-stored-key">{formatStoredYubiKey(keyValue)}</span>
|
||||
) : (
|
||||
<input
|
||||
className="input"
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
inputMode="verbatim"
|
||||
spellcheck={false}
|
||||
value={keyValue}
|
||||
onInput={(e) => updateYubiKey(index, normalizeYubiKeyFieldValue((e.currentTarget as HTMLInputElement).value))}
|
||||
/>
|
||||
)}
|
||||
{keyValue && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-danger small yubikey-remove-btn"
|
||||
title={t('txt_remove')}
|
||||
aria-label={t('txt_remove')}
|
||||
onClick={() => updateYubiKey(index, '')}
|
||||
>
|
||||
<Trash2 size={14} className="btn-icon" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div className="settings-checkbox-block">
|
||||
<strong>{t('txt_nfc_support')}</strong>
|
||||
<label className="checkbox-inline">
|
||||
<input type="checkbox" checked={yubiKeyNfc} onInput={(e) => setYubiKeyNfc((e.currentTarget as HTMLInputElement).checked)} />
|
||||
<span>{t('txt_yubikey_supports_nfc')}</span>
|
||||
</label>
|
||||
{t('txt_yubikey_supports_nfc_desc') && <div className="field-help">{t('txt_yubikey_supports_nfc_desc')}</div>}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ConfirmDialog>
|
||||
<ConfirmDialog
|
||||
open={recoveryCodeDialogOpen}
|
||||
title={`${t('txt_two_step_login')} ${t('txt_recovery_code')}`}
|
||||
|
||||
@@ -1,22 +1,27 @@
|
||||
import { useMemo } from 'preact/hooks';
|
||||
import {
|
||||
changeMasterPassword,
|
||||
bootstrapYubiKeyOtpApiCredentials,
|
||||
deleteAllAuthorizedDevices,
|
||||
deleteAuthorizedDevice,
|
||||
deleteAuthorizedDevices,
|
||||
deriveLoginHash,
|
||||
deleteAccountPasskey as deleteAccountPasskeyApi,
|
||||
enableAccountPasskeyDirectUnlock as enableAccountPasskeyDirectUnlockApi,
|
||||
disableYubiKeyOtp,
|
||||
getCurrentDeviceIdentifier,
|
||||
getApiKey,
|
||||
getAccountPasskeyAttestationOptions,
|
||||
getAccountPasskeyUpdateAssertionOptions,
|
||||
getTotpRecoveryCode,
|
||||
getYubiKeyOtpSettings,
|
||||
listAccountPasskeys,
|
||||
rotateApiKey,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
saveAccountPasskey,
|
||||
saveYubiKeyOtpApiCredentials,
|
||||
saveYubiKeyOtpSettings,
|
||||
setTotp,
|
||||
trustAuthorizedDevicePermanently,
|
||||
updateAuthorizedDeviceName,
|
||||
@@ -32,7 +37,7 @@ import {
|
||||
import { t } from '@/lib/i18n';
|
||||
import type { AppConfirmState } from '@/components/AppGlobalOverlays';
|
||||
import type { AuthedFetch } from '@/lib/api/shared';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState } from '@/lib/types';
|
||||
import type { AccountPasskeyCredential, AuthorizedDevice, Profile, SessionState, YubiKeyOtpSettings } from '@/lib/types';
|
||||
|
||||
type Notify = (type: 'success' | 'error' | 'warning', text: string) => void;
|
||||
|
||||
@@ -47,7 +52,7 @@ interface UseAccountSecurityActionsOptions {
|
||||
onNotify: Notify;
|
||||
onProfileUpdated: (profile: Profile) => void;
|
||||
onSetConfirm: (next: AppConfirmState | null) => void;
|
||||
refetchTotpStatus: () => Promise<unknown>;
|
||||
refetchTwoFactorStatus: () => Promise<unknown>;
|
||||
refetchAuthorizedDevices: () => Promise<unknown>;
|
||||
}
|
||||
|
||||
@@ -63,7 +68,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
onNotify,
|
||||
onProfileUpdated,
|
||||
onSetConfirm,
|
||||
refetchTotpStatus,
|
||||
refetchTwoFactorStatus,
|
||||
refetchAuthorizedDevices,
|
||||
} = options;
|
||||
|
||||
@@ -187,13 +192,71 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
const derived = await deriveLoginHash(profile.email, disableTotpPassword, defaultKdfIterations);
|
||||
await setTotp(authedFetch, { enabled: false, masterPasswordHash: derived.hash });
|
||||
clearDisableTotpDialog();
|
||||
await refetchTotpStatus();
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_totp_disabled'));
|
||||
} catch (error) {
|
||||
onNotify('error', error instanceof Error ? error.message : t('txt_disable_totp_failed'));
|
||||
}
|
||||
},
|
||||
|
||||
async getYubiKeySettings(masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
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 getYubiKeyOtpSettings(authedFetch, derived.hash);
|
||||
},
|
||||
|
||||
async saveYubiKeySettings(keys: string[], nfc: boolean, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
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 saveYubiKeyOtpSettings(authedFetch, { keys, nfc, masterPasswordHash: derived.hash });
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikeys_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async saveYubiKeyApiCredentials(clientId: string, secretKey: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
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 saveYubiKeyOtpApiCredentials(authedFetch, {
|
||||
masterPasswordHash: derived.hash,
|
||||
yubicoClientId: clientId,
|
||||
yubicoSecretKey: secretKey,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_config_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async bootstrapYubiKeyApiCredentials(otp: string, masterPassword: string): Promise<YubiKeyOtpSettings> {
|
||||
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 bootstrapYubiKeyOtpApiCredentials(authedFetch, {
|
||||
masterPasswordHash: derived.hash,
|
||||
otp,
|
||||
});
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_config_updated'));
|
||||
return settings;
|
||||
},
|
||||
|
||||
async disableYubiKey(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 disableYubiKeyOtp(authedFetch, derived.hash);
|
||||
await refetchTwoFactorStatus();
|
||||
onNotify('success', t('txt_yubikey_disabled'));
|
||||
},
|
||||
|
||||
async getRecoveryCode(masterPassword: string): Promise<string> {
|
||||
if (!profile) throw new Error(t('txt_profile_unavailable'));
|
||||
const normalized = String(masterPassword || '');
|
||||
@@ -476,7 +539,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
|
||||
session?.symEncKey,
|
||||
session?.symMacKey,
|
||||
refetchAuthorizedDevices,
|
||||
refetchTotpStatus,
|
||||
refetchTwoFactorStatus,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
+121
-6
@@ -7,6 +7,7 @@ import type {
|
||||
SessionState,
|
||||
TokenError,
|
||||
TokenSuccess,
|
||||
YubiKeyOtpSettings,
|
||||
} from '../types';
|
||||
import type { AccountPasskeyAssertion, AccountPasskeyPrfKeySet } from '../account-passkeys';
|
||||
import { recordNodeWardenReachable, recordNodeWardenUnreachable } from '../network-status';
|
||||
@@ -240,6 +241,7 @@ export async function loginWithPassword(
|
||||
passwordHash: string,
|
||||
options?: {
|
||||
totpCode?: string;
|
||||
twoFactorProvider?: number;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
@@ -259,7 +261,7 @@ export async function loginWithPassword(
|
||||
body.set('twoFactorProvider', '5');
|
||||
body.set('twoFactorToken', rememberedToken);
|
||||
} else if (options?.totpCode) {
|
||||
body.set('twoFactorProvider', '0');
|
||||
body.set('twoFactorProvider', String(options.twoFactorProvider ?? 0));
|
||||
body.set('twoFactorToken', options.totpCode);
|
||||
if (options.rememberDevice) {
|
||||
body.set('twoFactorRemember', '1');
|
||||
@@ -650,6 +652,110 @@ export async function setTotp(
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeYubiKeySettings(raw: any): YubiKeyOtpSettings {
|
||||
return {
|
||||
enabled: !!(raw?.enabled ?? raw?.Enabled),
|
||||
keys: [
|
||||
String(raw?.key1 ?? raw?.Key1 ?? ''),
|
||||
String(raw?.key2 ?? raw?.Key2 ?? ''),
|
||||
String(raw?.key3 ?? raw?.Key3 ?? ''),
|
||||
String(raw?.key4 ?? raw?.Key4 ?? ''),
|
||||
String(raw?.key5 ?? raw?.Key5 ?? ''),
|
||||
],
|
||||
nfc: !!(raw?.nfc ?? raw?.Nfc),
|
||||
yubicoConfigured: !!(raw?.yubicoConfigured ?? raw?.YubicoConfigured),
|
||||
yubicoClientId: String(raw?.yubicoClientId ?? raw?.YubicoClientId ?? ''),
|
||||
yubicoSecretKey: String(raw?.yubicoSecretKey ?? raw?.YubicoSecretKey ?? ''),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getYubiKeyOtpSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/get-yubikey', {
|
||||
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 normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function saveYubiKeyOtpSettings(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { keys: string[]; nfc: boolean; masterPasswordHash: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
key1: payload.keys[0] || '',
|
||||
key2: payload.keys[1] || '',
|
||||
key3: payload.keys[2] || '',
|
||||
key4: payload.keys[3] || '',
|
||||
key5: payload.keys[4] || '',
|
||||
nfc: payload.nfc,
|
||||
masterPasswordHash: payload.masterPasswordHash,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_yubikey_update_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function saveYubiKeyOtpApiCredentials(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { masterPasswordHash: string; yubicoClientId: string; yubicoSecretKey: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey/config', {
|
||||
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_yubikey_config_update_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function bootstrapYubiKeyOtpApiCredentials(
|
||||
authedFetch: AuthedFetch,
|
||||
payload: { masterPasswordHash: string; otp: string }
|
||||
): Promise<YubiKeyOtpSettings> {
|
||||
const resp = await authedFetch('/api/two-factor/yubikey/bootstrap', {
|
||||
method: 'POST',
|
||||
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_yubikey_auto_config_failed')));
|
||||
}
|
||||
return normalizeYubiKeySettings(await parseJson<unknown>(resp));
|
||||
}
|
||||
|
||||
export async function disableYubiKeyOtp(
|
||||
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: 3, masterPasswordHash }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const body = await parseJson<TokenError>(resp);
|
||||
throw new Error(translateServerError(body?.error_description || body?.error, t('txt_disable_yubikey_failed')));
|
||||
}
|
||||
}
|
||||
|
||||
export async function verifyMasterPassword(
|
||||
authedFetch: AuthedFetch,
|
||||
masterPasswordHash: string
|
||||
@@ -807,11 +913,20 @@ export async function getVaultRevisionDate(authedFetch: AuthedFetch): Promise<nu
|
||||
return stamp;
|
||||
}
|
||||
|
||||
export async function getTotpStatus(authedFetch: AuthedFetch): Promise<{ enabled: boolean }> {
|
||||
const resp = await authedFetch('/api/accounts/totp');
|
||||
if (!resp.ok) throw new Error('Failed to load TOTP status');
|
||||
const body = (await parseJson<{ enabled?: boolean }>(resp)) || {};
|
||||
return { enabled: !!body.enabled };
|
||||
export async function getTwoFactorProviderStatus(authedFetch: AuthedFetch): Promise<{ totpEnabled: boolean; yubikeyEnabled: 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)) || {};
|
||||
const providers = Array.isArray(body.data) ? body.data : Array.isArray(body.Data) ? body.Data : [];
|
||||
const enabledTypes = new Set(
|
||||
providers
|
||||
.map((provider: any) => Number(provider?.type ?? provider?.Type))
|
||||
.filter((type) => Number.isFinite(type))
|
||||
);
|
||||
return {
|
||||
totpEnabled: enabledTypes.has(0),
|
||||
yubikeyEnabled: enabledTypes.has(3),
|
||||
};
|
||||
}
|
||||
|
||||
export async function getTotpRecoveryCode(
|
||||
|
||||
@@ -34,6 +34,7 @@ export interface PendingTotp {
|
||||
passwordHash: string;
|
||||
masterKey: Uint8Array;
|
||||
kdfIterations: number;
|
||||
providerType: number;
|
||||
}
|
||||
|
||||
export interface PendingPasskeyPassword {
|
||||
@@ -70,10 +71,31 @@ export interface CompletedLogin {
|
||||
freshUserVerificationToken?: string | null;
|
||||
}
|
||||
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_YUBIKEY = 3;
|
||||
|
||||
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
|
||||
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
|
||||
}
|
||||
|
||||
function resolvePendingTwoFactorProvider(providers: unknown): 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
return TWO_FACTOR_PROVIDER_AUTHENTICATOR;
|
||||
}
|
||||
|
||||
export type PasswordLoginResult =
|
||||
| { kind: 'success'; login: CompletedLogin }
|
||||
| { kind: 'totp'; pendingTotp: PendingTotp }
|
||||
@@ -425,6 +447,7 @@ export async function performPasswordLogin(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -498,6 +521,7 @@ export async function performTotpLogin(
|
||||
): Promise<CompletedLogin> {
|
||||
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
|
||||
totpCode: totpCode.trim(),
|
||||
twoFactorProvider: pendingTotp.providerType,
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
@@ -615,6 +639,7 @@ export async function performUnlock(
|
||||
passwordHash: derived.hash,
|
||||
masterKey: derived.masterKey,
|
||||
kdfIterations: derived.kdfIterations,
|
||||
providerType: resolvePendingTwoFactorProvider(tokenError.TwoFactorProviders),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -27,6 +27,35 @@ const en: Record<string, string> = {
|
||||
"txt_passkey_provider_help": "Use a FIDO2-compatible security key or biometric authenticator.",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP security key",
|
||||
"txt_yubico_otp_security_key_help": "Use a YubiKey 4, 5, or NEO device.",
|
||||
"txt_yubikey_setup_intro": "Insert your YubiKey into a USB port. Select the first empty YubiKey field below, touch the YubiKey button, then save the form.",
|
||||
"txt_yubikey_plug_in": "Insert your YubiKey into a USB port.",
|
||||
"txt_yubikey_select_empty_field": "Select the first empty YubiKey input field below.",
|
||||
"txt_yubikey_touch_button": "Touch the YubiKey button.",
|
||||
"txt_yubikey_save_form": "Save the form.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC support",
|
||||
"txt_yubikey_supports_nfc": "One of my keys supports NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "If one of your YubiKeys supports NFC, mobile apps can prompt you when NFC is available.",
|
||||
"txt_disable_all_keys": "Disable all keys",
|
||||
"txt_yubikeys_updated": "YubiKeys updated",
|
||||
"txt_yubikey_update_failed": "Failed to update YubiKeys",
|
||||
"txt_disable_yubikey_failed": "Failed to disable YubiKeys",
|
||||
"txt_yubikey_disabled": "YubiKeys disabled",
|
||||
"txt_yubikey_enabled": "YubiKey is enabled.",
|
||||
"txt_yubikey_config_required": "Yubico validation is not configured",
|
||||
"txt_yubikey_config_required_help": "Enter one YubiKey OTP first. NodeWarden will automatically request and save the instance Client ID and Secret key, then open the YubiKey setup form.",
|
||||
"txt_otp_from_yubikey": "OTP from YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Please input YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey verification failed",
|
||||
"txt_press_yubikey_to_authenticate": "Press your YubiKey to authenticate.",
|
||||
"txt_yubikey_auto_configure": "Get and save automatically",
|
||||
"txt_yubikey_validation_credentials": "Yubico validation credentials",
|
||||
"txt_view": "View",
|
||||
"txt_yubikey_config_updated": "Yubico validation credentials updated",
|
||||
"txt_yubikey_config_update_failed": "Failed to update Yubico validation credentials",
|
||||
"txt_yubikey_auto_config_failed": "Failed to get Yubico validation credentials",
|
||||
"txt_yubikey_reconfigure_help": "Enter a fresh OTP to request and replace these credentials automatically.",
|
||||
"txt_yubikey_auto_configure_again": "Get again automatically",
|
||||
"txt_setting_coming_soon": "Coming soon.",
|
||||
"txt_totp_manage_intro": "Scan the QR code or enter the key in your authenticator app, then enter the verification code.",
|
||||
"txt_two_step_recovery_code_warning": "If you cannot access your two-step login provider, your one-time recovery code can be used to disable two-step login. Store the recovery code somewhere safe.",
|
||||
@@ -860,6 +889,8 @@ const en: Record<string, string> = {
|
||||
"txt_scope": "scope",
|
||||
"txt_grant_type": "grant_type",
|
||||
"txt_refresh": "Refresh",
|
||||
"txt_refresh_status": "Refresh status",
|
||||
"txt_load_failed": "Failed to load",
|
||||
"txt_refresh_in_seconds_s": "Refresh in {seconds}s",
|
||||
"txt_regenerate": "Regenerate",
|
||||
"txt_registration_succeeded_please_sign_in": "Registration succeeded. Please sign in.",
|
||||
|
||||
@@ -27,6 +27,35 @@ const es: Record<string, string> = {
|
||||
"txt_passkey_provider_help": "Usa una llave de seguridad compatible con FIDO2 o autenticación biométrica.",
|
||||
"txt_yubico_otp_security_key": "Llave de seguridad Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Usa un dispositivo YubiKey 4, 5 o NEO.",
|
||||
"txt_yubikey_setup_intro": "Inserta tu YubiKey en un puerto USB. Selecciona el primer campo YubiKey vacío, toca el botón de la YubiKey y guarda el formulario.",
|
||||
"txt_yubikey_plug_in": "Inserta tu YubiKey en un puerto USB.",
|
||||
"txt_yubikey_select_empty_field": "Selecciona el primer campo YubiKey vacío.",
|
||||
"txt_yubikey_touch_button": "Toca el botón de la YubiKey.",
|
||||
"txt_yubikey_save_form": "Guarda el formulario.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "Compatibilidad NFC",
|
||||
"txt_yubikey_supports_nfc": "Una de mis llaves admite NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "Si una de tus YubiKeys admite NFC, las apps móviles pueden avisarte cuando NFC esté disponible.",
|
||||
"txt_disable_all_keys": "Desactivar todas las llaves",
|
||||
"txt_yubikeys_updated": "YubiKeys actualizadas",
|
||||
"txt_yubikey_update_failed": "No se pudieron actualizar las YubiKeys",
|
||||
"txt_disable_yubikey_failed": "No se pudieron desactivar las YubiKeys",
|
||||
"txt_yubikey_disabled": "YubiKeys desactivadas",
|
||||
"txt_yubikey_enabled": "YubiKey activada.",
|
||||
"txt_yubikey_config_required": "La validación de Yubico no está configurada",
|
||||
"txt_yubikey_config_required_help": "Introduce primero un OTP de YubiKey. NodeWarden solicitará y guardará automáticamente el Client ID y la Secret key de la instancia, y luego abrirá el formulario de YubiKey.",
|
||||
"txt_otp_from_yubikey": "OTP de YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Introduce el OTP de YubiKey",
|
||||
"txt_yubikey_verify_failed": "No se pudo verificar la YubiKey",
|
||||
"txt_press_yubikey_to_authenticate": "Pulsa tu YubiKey para autenticarte.",
|
||||
"txt_yubikey_auto_configure": "Obtener y guardar automáticamente",
|
||||
"txt_yubikey_validation_credentials": "Credenciales de validación de Yubico",
|
||||
"txt_view": "Ver",
|
||||
"txt_yubikey_config_updated": "Credenciales de validación de Yubico actualizadas",
|
||||
"txt_yubikey_config_update_failed": "No se pudieron actualizar las credenciales de validación de Yubico",
|
||||
"txt_yubikey_auto_config_failed": "No se pudieron obtener las credenciales de validación de Yubico",
|
||||
"txt_yubikey_reconfigure_help": "Introduce un OTP nuevo para solicitar y reemplazar estas credenciales automáticamente.",
|
||||
"txt_yubikey_auto_configure_again": "Obtener de nuevo automáticamente",
|
||||
"txt_setting_coming_soon": "Próximamente.",
|
||||
"txt_totp_manage_intro": "Escanea el código QR o introduce la clave en tu aplicación autenticadora, luego escribe el código de verificación.",
|
||||
"txt_two_step_recovery_code_warning": "Si no puedes acceder a tu proveedor de inicio de sesión en dos pasos, tu código de recuperación de un solo uso puede desactivar el inicio de sesión en dos pasos. Guarda el código en un lugar seguro.",
|
||||
@@ -860,6 +889,8 @@ const es: Record<string, string> = {
|
||||
"txt_scope": "Ámbito",
|
||||
"txt_grant_type": "Tipo de concesión",
|
||||
"txt_refresh": "Actualizar",
|
||||
"txt_refresh_status": "Actualizar estado",
|
||||
"txt_load_failed": "No se pudo cargar",
|
||||
"txt_refresh_in_seconds_s": "Actualizar en {seconds}s",
|
||||
"txt_regenerate": "Regenerar",
|
||||
"txt_registration_succeeded_please_sign_in": "Registro completado. Inicie sesión.",
|
||||
|
||||
@@ -28,6 +28,35 @@ const ru: Record<string, string> = {
|
||||
"txt_passkey_provider_help": "Используйте FIDO2-совместимый ключ безопасности или биометрический аутентификатор.",
|
||||
"txt_yubico_otp_security_key": "Ключ безопасности Yubico OTP",
|
||||
"txt_yubico_otp_security_key_help": "Используйте устройство YubiKey 4, 5 или NEO.",
|
||||
"txt_yubikey_setup_intro": "Вставьте YubiKey в USB-порт. Выберите первое пустое поле YubiKey ниже, коснитесь кнопки YubiKey и сохраните форму.",
|
||||
"txt_yubikey_plug_in": "Вставьте YubiKey в USB-порт.",
|
||||
"txt_yubikey_select_empty_field": "Выберите первое пустое поле YubiKey ниже.",
|
||||
"txt_yubikey_touch_button": "Коснитесь кнопки YubiKey.",
|
||||
"txt_yubikey_save_form": "Сохраните форму.",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "Поддержка NFC",
|
||||
"txt_yubikey_supports_nfc": "Один из моих ключей поддерживает NFC.",
|
||||
"txt_yubikey_supports_nfc_desc": "Если один из ваших YubiKey поддерживает NFC, мобильные приложения смогут подсказать вам, когда NFC доступен.",
|
||||
"txt_disable_all_keys": "Отключить все ключи",
|
||||
"txt_yubikeys_updated": "YubiKey обновлены",
|
||||
"txt_yubikey_update_failed": "Не удалось обновить YubiKey",
|
||||
"txt_disable_yubikey_failed": "Не удалось отключить YubiKey",
|
||||
"txt_yubikey_disabled": "YubiKey отключены",
|
||||
"txt_yubikey_enabled": "YubiKey включен.",
|
||||
"txt_yubikey_config_required": "Проверка Yubico не настроена",
|
||||
"txt_yubikey_config_required_help": "Сначала введите один OTP с YubiKey. NodeWarden автоматически запросит и сохранит Client ID и Secret key экземпляра, затем откроет форму настройки YubiKey.",
|
||||
"txt_otp_from_yubikey": "OTP с YubiKey",
|
||||
"txt_please_input_yubikey_otp": "Введите OTP с YubiKey",
|
||||
"txt_yubikey_verify_failed": "Не удалось проверить YubiKey",
|
||||
"txt_press_yubikey_to_authenticate": "Нажмите YubiKey для проверки.",
|
||||
"txt_yubikey_auto_configure": "Получить и сохранить автоматически",
|
||||
"txt_yubikey_validation_credentials": "Учетные данные проверки Yubico",
|
||||
"txt_view": "Показать",
|
||||
"txt_yubikey_config_updated": "Учетные данные проверки Yubico обновлены",
|
||||
"txt_yubikey_config_update_failed": "Не удалось обновить учетные данные проверки Yubico",
|
||||
"txt_yubikey_auto_config_failed": "Не удалось получить учетные данные проверки Yubico",
|
||||
"txt_yubikey_reconfigure_help": "Введите новый OTP, чтобы автоматически запросить и заменить эти учетные данные.",
|
||||
"txt_yubikey_auto_configure_again": "Получить снова автоматически",
|
||||
"txt_setting_coming_soon": "Скоро появится.",
|
||||
"txt_totp_manage_intro": "Отсканируйте QR-код или введите ключ в приложении-аутентификаторе, затем введите код проверки.",
|
||||
"txt_two_step_recovery_code_warning": "Если вы не можете получить доступ к поставщику двухэтапного входа, одноразовый код восстановления можно использовать для отключения двухэтапного входа. Сохраните код в надежном месте.",
|
||||
@@ -860,6 +889,8 @@ const ru: Record<string, string> = {
|
||||
"txt_scope": "Область доступа",
|
||||
"txt_grant_type": "Тип авторизации",
|
||||
"txt_refresh": "Обновить",
|
||||
"txt_refresh_status": "Обновить статус",
|
||||
"txt_load_failed": "Не удалось загрузить",
|
||||
"txt_refresh_in_seconds_s": "Обновить через {seconds} с.",
|
||||
"txt_regenerate": "Регенерировать",
|
||||
"txt_registration_succeeded_please_sign_in": "Регистрация прошла успешно. Пожалуйста, войдите в систему.",
|
||||
|
||||
@@ -27,6 +27,35 @@ const zhCN: Record<string, string> = {
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密钥或生物识别验证器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密钥",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 设备。",
|
||||
"txt_yubikey_setup_intro": "将 YubiKey 插入计算机的 USB 端口。在下面选择第一个空的 YubiKey 输入字段。触摸 YubiKey 的按钮、保存。",
|
||||
"txt_yubikey_plug_in": "将 YubiKey 插入计算机的 USB 端口",
|
||||
"txt_yubikey_select_empty_field": "在下面选择第一个空的 YubiKey 输入字段",
|
||||
"txt_yubikey_touch_button": "触摸 YubiKey 的按钮、保存",
|
||||
"txt_yubikey_save_form": "保存",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC 支持",
|
||||
"txt_yubikey_supports_nfc": "我的某个密钥支持 NFC",
|
||||
"txt_yubikey_supports_nfc_desc": "",
|
||||
"txt_disable_all_keys": "停用全部密钥",
|
||||
"txt_yubikeys_updated": "YubiKey 已更新",
|
||||
"txt_yubikey_update_failed": "更新 YubiKey 失败",
|
||||
"txt_disable_yubikey_failed": "停用 YubiKey 失败",
|
||||
"txt_yubikey_disabled": "YubiKey 已停用",
|
||||
"txt_yubikey_enabled": "YubiKey 已启用。",
|
||||
"txt_yubikey_config_required": "尚未配置 Yubico 验证",
|
||||
"txt_yubikey_config_required_help": "请先输入一次 YubiKey OTP。NodeWarden 会自动获取并保存实例级 Client ID 和 Secret key,成功后再进入 YubiKey 设置表单。",
|
||||
"txt_otp_from_yubikey": "来自 YubiKey 的 OTP",
|
||||
"txt_please_input_yubikey_otp": "请输入 YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey 验证失败",
|
||||
"txt_press_yubikey_to_authenticate": "按下 YubiKey 进行验证。",
|
||||
"txt_yubikey_auto_configure": "自动获取并保存",
|
||||
"txt_yubikey_validation_credentials": "Yubico 验证凭据",
|
||||
"txt_view": "查看",
|
||||
"txt_yubikey_config_updated": "Yubico 验证凭据已更新",
|
||||
"txt_yubikey_config_update_failed": "更新 Yubico 验证凭据失败",
|
||||
"txt_yubikey_auto_config_failed": "获取 Yubico 验证凭据失败",
|
||||
"txt_yubikey_reconfigure_help": "输入一个新的 OTP,可以重新自动获取并替换当前凭据。",
|
||||
"txt_yubikey_auto_configure_again": "重新自动获取",
|
||||
"txt_setting_coming_soon": "即将推出。",
|
||||
"txt_totp_manage_intro": "扫描二维码或在验证器 App 中输入密钥,然后输入验证码。",
|
||||
"txt_two_step_recovery_code_warning": "当您无法访问两步登录提供程序时,您的一次性恢复代码可用于停用两步登录。请将其妥善保管。",
|
||||
@@ -860,6 +889,8 @@ const zhCN: Record<string, string> = {
|
||||
"txt_scope": "权限范围",
|
||||
"txt_grant_type": "授权类型",
|
||||
"txt_refresh": "刷新",
|
||||
"txt_refresh_status": "刷新状态",
|
||||
"txt_load_failed": "加载失败",
|
||||
"txt_refresh_in_seconds_s": "{seconds} 秒后刷新",
|
||||
"txt_regenerate": "重新生成",
|
||||
"txt_registration_succeeded_please_sign_in": "注册成功,请登录",
|
||||
|
||||
@@ -27,6 +27,35 @@ const zhTW: Record<string, string> = {
|
||||
"txt_passkey_provider_help": "使用兼容 FIDO2 的安全密鑰或生物識別驗證器。",
|
||||
"txt_yubico_otp_security_key": "Yubico OTP 安全密鑰",
|
||||
"txt_yubico_otp_security_key_help": "使用 YubiKey 4、5 或 NEO 裝置。",
|
||||
"txt_yubikey_setup_intro": "將 YubiKey 插入電腦的 USB 連接埠。在下方選擇第一個空的 YubiKey 輸入欄位,觸摸 YubiKey 按鈕,然後保存表單。",
|
||||
"txt_yubikey_plug_in": "將 YubiKey 插入電腦的 USB 連接埠。",
|
||||
"txt_yubikey_select_empty_field": "在下方選擇第一個空的 YubiKey 輸入欄位。",
|
||||
"txt_yubikey_touch_button": "觸摸 YubiKey 按鈕。",
|
||||
"txt_yubikey_save_form": "保存表單。",
|
||||
"txt_yubikey_x": "YubiKey {index}",
|
||||
"txt_nfc_support": "NFC 支援",
|
||||
"txt_yubikey_supports_nfc": "我的某個密鑰支援 NFC。",
|
||||
"txt_yubikey_supports_nfc_desc": "如果您的某個 YubiKey 支援 NFC,行動裝置偵測到 NFC 可用時會提示您。",
|
||||
"txt_disable_all_keys": "停用全部密鑰",
|
||||
"txt_yubikeys_updated": "YubiKey 已更新",
|
||||
"txt_yubikey_update_failed": "更新 YubiKey 失敗",
|
||||
"txt_disable_yubikey_failed": "停用 YubiKey 失敗",
|
||||
"txt_yubikey_disabled": "YubiKey 已停用",
|
||||
"txt_yubikey_enabled": "YubiKey 已啟用。",
|
||||
"txt_yubikey_config_required": "尚未配置 Yubico 驗證",
|
||||
"txt_yubikey_config_required_help": "請先輸入一次 YubiKey OTP。NodeWarden 會自動取得並保存實例級 Client ID 和 Secret key,成功後再進入 YubiKey 設定表單。",
|
||||
"txt_otp_from_yubikey": "來自 YubiKey 的 OTP",
|
||||
"txt_please_input_yubikey_otp": "請輸入 YubiKey OTP",
|
||||
"txt_yubikey_verify_failed": "YubiKey 驗證失敗",
|
||||
"txt_press_yubikey_to_authenticate": "按下 YubiKey 進行驗證。",
|
||||
"txt_yubikey_auto_configure": "自動取得並保存",
|
||||
"txt_yubikey_validation_credentials": "Yubico 驗證憑據",
|
||||
"txt_view": "查看",
|
||||
"txt_yubikey_config_updated": "Yubico 驗證憑據已更新",
|
||||
"txt_yubikey_config_update_failed": "更新 Yubico 驗證憑據失敗",
|
||||
"txt_yubikey_auto_config_failed": "取得 Yubico 驗證憑據失敗",
|
||||
"txt_yubikey_reconfigure_help": "輸入一個新的 OTP,可以重新自動取得並替換目前憑據。",
|
||||
"txt_yubikey_auto_configure_again": "重新自動取得",
|
||||
"txt_setting_coming_soon": "即將推出。",
|
||||
"txt_totp_manage_intro": "掃描二維碼或在驗證器 App 中輸入密鑰,然後輸入驗證碼。",
|
||||
"txt_two_step_recovery_code_warning": "當您無法訪問兩步登入提供程序時,您的一次性恢復代碼可用於停用兩步登入。請將其妥善保管。",
|
||||
@@ -860,6 +889,8 @@ const zhTW: Record<string, string> = {
|
||||
"txt_scope": "權限範圍",
|
||||
"txt_grant_type": "授權類型",
|
||||
"txt_refresh": "刷新",
|
||||
"txt_refresh_status": "刷新狀態",
|
||||
"txt_load_failed": "載入失敗",
|
||||
"txt_refresh_in_seconds_s": "{seconds} 秒後刷新",
|
||||
"txt_regenerate": "重新生成",
|
||||
"txt_registration_succeeded_please_sign_in": "註冊成功,請登錄",
|
||||
|
||||
@@ -15,6 +15,7 @@ export interface Profile {
|
||||
name: string;
|
||||
key: string;
|
||||
masterPasswordHint?: string | null;
|
||||
yubikeyEnabled?: boolean;
|
||||
privateKey?: string | null;
|
||||
publicKey?: string | null;
|
||||
role: 'admin' | 'user';
|
||||
@@ -295,6 +296,15 @@ export interface WebBootstrapResponse {
|
||||
registrationInviteRequired?: boolean;
|
||||
}
|
||||
|
||||
export interface YubiKeyOtpSettings {
|
||||
enabled: boolean;
|
||||
keys: [string, string, string, string, string];
|
||||
nfc: boolean;
|
||||
yubicoConfigured: boolean;
|
||||
yubicoClientId: string;
|
||||
yubicoSecretKey: string;
|
||||
}
|
||||
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
|
||||
@@ -745,6 +745,42 @@
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.yubikey-manage-dialog-body {
|
||||
@apply mt-3 space-y-4;
|
||||
}
|
||||
|
||||
.settings-plain-steps {
|
||||
@apply m-0 space-y-1 border-b pb-3 pl-5;
|
||||
border-color: var(--line);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.yubikey-input-row {
|
||||
@apply grid items-center gap-2;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.yubikey-stored-key {
|
||||
@apply block min-h-9 rounded-md border border-transparent px-0 py-2 text-sm text-slate-800 dark:text-slate-100;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.yubikey-remove-btn {
|
||||
@apply h-9 w-9 p-0;
|
||||
}
|
||||
|
||||
.settings-checkbox-block {
|
||||
@apply space-y-2;
|
||||
}
|
||||
|
||||
.checkbox-inline {
|
||||
@apply flex items-center gap-2 text-sm;
|
||||
}
|
||||
|
||||
.checkbox-inline input {
|
||||
@apply h-4 w-4;
|
||||
}
|
||||
|
||||
.dialog-close-btn {
|
||||
@apply absolute right-3 top-3 flex h-9 w-9 cursor-pointer items-center justify-center rounded-full border-0 p-0;
|
||||
background: transparent;
|
||||
|
||||
Reference in New Issue
Block a user