From 18e0396c0af864344c970f43922539bc3256e4dd Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 10 Jun 2026 12:09:25 +0800 Subject: [PATCH] feat: enhance account passkey functionality and improve error handling --- webapp/src/components/AppGlobalOverlays.tsx | 3 +- webapp/src/components/AppMainRoutes.tsx | 2 +- webapp/src/components/SettingsPage.tsx | 6 +-- webapp/src/hooks/useAccountSecurityActions.ts | 53 +++++++++++++++---- webapp/src/lib/account-passkeys.ts | 34 +++++++----- webapp/src/lib/i18n/locales/en.ts | 18 +++++++ webapp/src/lib/i18n/locales/es.ts | 18 +++++++ webapp/src/lib/i18n/locales/ru.ts | 18 +++++++ webapp/src/lib/i18n/locales/zh-CN.ts | 22 +++++++- webapp/src/lib/i18n/locales/zh-TW.ts | 22 +++++++- 10 files changed, 165 insertions(+), 31 deletions(-) diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index becbd6e..45f242f 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -12,6 +12,7 @@ export interface AppConfirmState { cancelText?: string; hideCancel?: boolean; onConfirm: () => void; + onCancel?: () => void; } interface AppGlobalOverlaysProps { @@ -49,7 +50,7 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { cancelText={props.confirm?.cancelText} hideCancel={props.confirm?.hideCancel} onConfirm={() => props.confirm?.onConfirm()} - onCancel={props.onCancelConfirm} + onCancel={props.confirm?.onCancel || props.onCancelConfirm} /> Promise; onRotateApiKey: (masterPassword: string) => Promise; onListAccountPasskeys: () => Promise; - onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise; + onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index bae1cd5..f304ae9 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -19,7 +19,7 @@ interface SettingsPageProps { onGetApiKey: (masterPassword: string) => Promise; onRotateApiKey: (masterPassword: string) => Promise; onListAccountPasskeys: () => Promise; - onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise; + onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise; onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise; onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise; onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void; @@ -179,8 +179,8 @@ export default function SettingsPage(props: SettingsPageProps) { setApiKeyDialogOpen(true); props.onNotify?.('success', t('txt_api_key_rotated')); } else if (masterPasswordPrompt === 'createPasskey') { - await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock); - await refreshAccountPasskeys(); + const credential = await props.onCreateAccountPasskey(accountPasskeyName, masterPassword, accountPasskeyDirectUnlock); + if (credential) await refreshAccountPasskeys(); } else if (masterPasswordPrompt === 'enablePasskeyDirectUnlock') { if (!accountPasskeyPromptId) throw new Error(t('txt_account_passkey_not_found')); await props.onEnableAccountPasskeyDirectUnlock(accountPasskeyPromptId, masterPassword); diff --git a/webapp/src/hooks/useAccountSecurityActions.ts b/webapp/src/hooks/useAccountSecurityActions.ts index 03945ad..682aa48 100644 --- a/webapp/src/hooks/useAccountSecurityActions.ts +++ b/webapp/src/hooks/useAccountSecurityActions.ts @@ -22,6 +22,7 @@ import { updateProfile, } from '@/lib/api/auth'; import { + AccountPasskeyPrfUnavailableError, assertAccountPasskey, buildAccountPasskeyPrfKeySet, buildAccountPasskeyPrfKeySetFromPrfKey, @@ -66,7 +67,29 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct } = options; return useMemo( - () => ({ + () => { + function confirmSaveLoginOnlyAccountPasskey(): Promise { + return new Promise((resolve) => { + let settled = false; + const finish = (shouldSave: boolean) => { + if (settled) return; + settled = true; + onSetConfirm(null); + resolve(shouldSave); + }; + onSetConfirm({ + title: t('txt_account_passkey_direct_unlock_unavailable_title'), + message: t('txt_account_passkey_direct_unlock_unavailable_message'), + confirmText: t('txt_save_login_only_passkey'), + cancelText: t('txt_do_not_save'), + showIcon: true, + onConfirm: () => finish(true), + onCancel: () => finish(false), + }); + }); + } + + return ({ async changePassword(currentPassword: string, nextPassword: string, nextPassword2: string) { if (!profile) return; if (!currentPassword || !nextPassword) { @@ -188,7 +211,7 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct return listAccountPasskeys(authedFetch); }, - async createAccountPasskey(name: string, masterPassword: string, directUnlock: boolean): Promise { + async createAccountPasskey(name: string, masterPassword: string, directUnlock: boolean): Promise { if (!profile) throw new Error(t('txt_profile_unavailable')); const normalizedPassword = String(masterPassword || ''); if (!normalizedPassword) throw new Error(t('txt_master_password_is_required')); @@ -197,21 +220,32 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct const options = await getAccountPasskeyAttestationOptions(authedFetch, derived.hash); const pending = await createAccountPasskeyCredential(options); let keySet = null; + let savedWithoutDirectUnlock = false; if (directUnlock) { if (!session?.symEncKey || !session?.symMacKey) throw new Error(t('txt_vault_key_unavailable')); - keySet = await buildAccountPasskeyPrfKeySet(pending, { - symEncKey: session.symEncKey, - symMacKey: session.symMacKey, - }); + try { + keySet = await buildAccountPasskeyPrfKeySet(pending, { + symEncKey: session.symEncKey, + symMacKey: session.symMacKey, + }); + } catch (error) { + if (!(error instanceof AccountPasskeyPrfUnavailableError)) throw error; + const shouldSaveLoginOnly = await confirmSaveLoginOnlyAccountPasskey(); + if (!shouldSaveLoginOnly) { + onNotify('warning', t('txt_account_passkey_not_saved')); + return null; + } + savedWithoutDirectUnlock = true; + } } const credential = await saveAccountPasskey(authedFetch, { name: normalizedName, token: pending.token, deviceResponse: pending.request, - supportsPrf: pending.supportsPrf, + supportsPrf: keySet ? true : savedWithoutDirectUnlock ? false : pending.supportsPrf, keySet, }); - onNotify('success', t('txt_account_passkey_saved')); + onNotify('success', savedWithoutDirectUnlock ? t('txt_account_passkey_saved_login_only') : t('txt_account_passkey_saved')); return credential; }, @@ -369,7 +403,8 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct }, }); }, - }), + }); + }, [ authedFetch, clearDisableTotpDialog, diff --git a/webapp/src/lib/account-passkeys.ts b/webapp/src/lib/account-passkeys.ts index 7b612fa..a922b8b 100644 --- a/webapp/src/lib/account-passkeys.ts +++ b/webapp/src/lib/account-passkeys.ts @@ -1,4 +1,5 @@ import { base64ToBytes, bytesToBase64, decryptBw, encryptBw, hkdfExpand, toBufferSource } from './crypto'; +import { t } from './i18n'; import type { AccountPasskeyPrfOption } from './types'; const LOGIN_WITH_PRF_SALT = 'passwordless-login'; @@ -23,6 +24,13 @@ export interface AccountPasskeyPrfKeySet { encryptedPrivateKey: string; } +export class AccountPasskeyPrfUnavailableError extends Error { + constructor() { + super(t('txt_account_passkey_direct_unlock_unavailable_error')); + this.name = 'AccountPasskeyPrfUnavailableError'; + } +} + function bytesToBase64Url(bytes: Uint8Array): string { return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); } @@ -38,7 +46,7 @@ function toArrayBuffer(bytes: Uint8Array): ArrayBuffer { } function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions { - if (!options || typeof options !== 'object') throw new Error('Invalid passkey creation options'); + if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_creation_options')); return { ...options, challenge: toArrayBuffer(base64UrlToBytes(options.challenge)), @@ -56,7 +64,7 @@ function cloneCreationOptions(options: any): PublicKeyCredentialCreationOptions } function cloneRequestOptions(options: any): PublicKeyCredentialRequestOptions { - if (!options || typeof options !== 'object') throw new Error('Invalid passkey assertion options'); + if (!options || typeof options !== 'object') throw new Error(t('txt_invalid_passkey_assertion_options')); return { ...options, challenge: toArrayBuffer(base64UrlToBytes(options.challenge)), @@ -131,7 +139,7 @@ function publicKeyCredentialBase(credential: PublicKeyCredential): Record { if (!(credential.response instanceof AuthenticatorAssertionResponse)) { - throw new Error('Invalid passkey assertion response'); + throw new Error(t('txt_invalid_passkey_assertion_response')); } return { ...publicKeyCredentialBase(credential), @@ -148,7 +156,7 @@ function assertionRequest(credential: PublicKeyCredential): Record { if (!(credential.response instanceof AuthenticatorAttestationResponse)) { - throw new Error('Invalid passkey registration response'); + throw new Error(t('txt_invalid_passkey_registration_response')); } const transports = typeof credential.response.getTransports === 'function' ? credential.response.getTransports() @@ -167,7 +175,7 @@ export async function assertAccountPasskey( response: { options: unknown; token: string } ): Promise { if (!window.PublicKeyCredential || !navigator.credentials) { - throw new Error('Passkey is not supported in this browser'); + throw new Error(t('txt_passkey_browser_not_supported')); } const nativeOptions = cloneRequestOptions(response.options); (nativeOptions as any).extensions = { @@ -176,7 +184,7 @@ export async function assertAccountPasskey( }; const credential = await navigator.credentials.get({ publicKey: nativeOptions }); if (!(credential instanceof PublicKeyCredential)) { - throw new Error('No passkey was selected'); + throw new Error(t('txt_no_passkey_selected')); } const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first; return { @@ -190,7 +198,7 @@ export async function createAccountPasskeyCredential( response: { options: unknown; token: string } ): Promise { if (!window.PublicKeyCredential || !navigator.credentials) { - throw new Error('Passkey is not supported in this browser'); + throw new Error(t('txt_passkey_browser_not_supported')); } const nativeOptions = cloneCreationOptions(response.options); (nativeOptions as any).extensions = { @@ -199,7 +207,7 @@ export async function createAccountPasskeyCredential( }; const credential = await navigator.credentials.create({ publicKey: nativeOptions }); if (!(credential instanceof PublicKeyCredential)) { - throw new Error('No passkey was created'); + throw new Error(t('txt_no_passkey_created')); } const supportsPrf = !!(credential.getClientExtensionResults() as any).prf?.enabled; return { @@ -214,7 +222,7 @@ export async function createAccountPasskeyCredential( function parseRsaEncryptedUserKey(value: string): Uint8Array { const text = String(value || '').trim(); const [type, payload] = text.split('.'); - if (type !== '4' || !payload) throw new Error('Unsupported encrypted user key'); + if (type !== '4' || !payload) throw new Error(t('txt_unsupported_encrypted_user_key')); return base64ToBytes(payload); } @@ -236,11 +244,11 @@ export async function buildAccountPasskeyPrfKeySet( }; const assertion = await navigator.credentials.get({ publicKey: assertionOptions }); if (!(assertion instanceof PublicKeyCredential)) { - throw new Error('Passkey verification failed'); + throw new Error(t('txt_passkey_verification_failed')); } const prfResult = (assertion.getClientExtensionResults() as any).prf?.results?.first; if (!prfResult) { - throw new Error('This passkey does not support direct vault unlock'); + throw new AccountPasskeyPrfUnavailableError(); } return buildAccountPasskeyPrfKeySetFromPrfKey(await prfOutputToKey(prfResult), userKey); } @@ -285,7 +293,7 @@ export async function unlockVaultKeyWithAccountPasskeyPrf( const encryptedPrivateKey = option.EncryptedPrivateKey || option.encryptedPrivateKey || ''; const encryptedUserKey = option.EncryptedUserKey || option.encryptedUserKey || ''; if (!encryptedPrivateKey || !encryptedUserKey) { - throw new Error('Passkey cannot unlock this vault'); + throw new Error(t('txt_passkey_cannot_unlock_vault')); } const privateKeyBytes = await decryptBw(encryptedPrivateKey, prfKey.slice(0, 32), prfKey.slice(32, 64)); const privateKey = await crypto.subtle.importKey( @@ -300,7 +308,7 @@ export async function unlockVaultKeyWithAccountPasskeyPrf( privateKey, toBufferSource(parseRsaEncryptedUserKey(encryptedUserKey)) )); - if (userKeyBytes.length < 64) throw new Error('Invalid passkey vault key'); + if (userKeyBytes.length < 64) throw new Error(t('txt_invalid_passkey_vault_key')); return { symEncKey: bytesToBase64(userKeyBytes.slice(0, 32)), symMacKey: bytesToBase64(userKeyBytes.slice(32, 64)), diff --git a/webapp/src/lib/i18n/locales/en.ts b/webapp/src/lib/i18n/locales/en.ts index 2b715e9..2107f32 100644 --- a/webapp/src/lib/i18n/locales/en.ts +++ b/webapp/src/lib/i18n/locales/en.ts @@ -644,6 +644,13 @@ const en: Record = { "txt_account_passkey_not_found": "Account passkey not found", "txt_account_passkey_prf_not_available": "This passkey cannot return a PRF key", "txt_account_passkey_direct_unlock_enabled": "Direct vault unlock enabled", + "txt_account_passkey_direct_unlock_unavailable_title": "Direct unlock unavailable", + "txt_account_passkey_direct_unlock_unavailable_message": "This passkey did not return a PRF key, so it cannot unlock the vault directly. You can still save it for account login; unlocking the vault will require your master password.", + "txt_account_passkey_direct_unlock_unavailable_error": "This passkey cannot unlock the vault directly", + "txt_account_passkey_saved_login_only": "Account passkey saved for login only", + "txt_account_passkey_not_saved": "Account passkey was not saved", + "txt_save_login_only_passkey": "Save for login only", + "txt_do_not_save": "Do not save", "txt_add_account_passkey": "Add account passkey", "txt_delete_account_passkey": "Delete account passkey", "txt_direct_unlock": "Direct unlock", @@ -654,6 +661,17 @@ const en: Record = { "txt_passkey_name": "Passkey name", "txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.", "txt_prf_not_supported": "PRF not supported", + "txt_invalid_passkey_creation_options": "Invalid passkey creation options", + "txt_invalid_passkey_assertion_options": "Invalid passkey verification options", + "txt_invalid_passkey_assertion_response": "Invalid passkey verification response", + "txt_invalid_passkey_registration_response": "Invalid passkey registration response", + "txt_passkey_browser_not_supported": "This browser does not support passkeys", + "txt_no_passkey_selected": "No passkey was selected", + "txt_no_passkey_created": "No passkey was created", + "txt_unsupported_encrypted_user_key": "Unsupported encrypted account key", + "txt_passkey_verification_failed": "Passkey verification failed", + "txt_passkey_cannot_unlock_vault": "This passkey cannot unlock this vault", + "txt_invalid_passkey_vault_key": "Invalid passkey vault key", "txt_phone": "Phone", "txt_please_input_email_and_password": "Please input email and password", "txt_please_input_master_password": "Please input master password", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index 5ce8d5d..d643de6 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -644,6 +644,13 @@ const es: Record = { "txt_account_passkey_not_found": "Passkey de cuenta no encontrada", "txt_account_passkey_prf_not_available": "Esta passkey no puede devolver una clave PRF", "txt_account_passkey_direct_unlock_enabled": "Desbloqueo directo activado", + "txt_account_passkey_direct_unlock_unavailable_title": "Desbloqueo directo no disponible", + "txt_account_passkey_direct_unlock_unavailable_message": "Esta passkey no devolvió una clave PRF, por lo que no puede desbloquear la bóveda directamente. Aun así puede guardarla para iniciar sesión; para desbloquear la bóveda necesitará la contraseña maestra.", + "txt_account_passkey_direct_unlock_unavailable_error": "Esta passkey no puede desbloquear la bóveda directamente", + "txt_account_passkey_saved_login_only": "Passkey de cuenta guardada solo para inicio de sesión", + "txt_account_passkey_not_saved": "La passkey de cuenta no se guardó", + "txt_save_login_only_passkey": "Guardar solo para inicio", + "txt_do_not_save": "No guardar", "txt_add_account_passkey": "Añadir passkey de cuenta", "txt_delete_account_passkey": "Eliminar passkey de cuenta", "txt_direct_unlock": "Desbloqueo directo", @@ -654,6 +661,17 @@ const es: Record = { "txt_passkey_name": "Nombre de passkey", "txt_passkey_requires_master_password": "Passkey verificada. Introduzca su contraseña maestra para desbloquear la bóveda.", "txt_prf_not_supported": "PRF no compatible", + "txt_invalid_passkey_creation_options": "Opciones de creación de passkey no válidas", + "txt_invalid_passkey_assertion_options": "Opciones de verificación de passkey no válidas", + "txt_invalid_passkey_assertion_response": "Respuesta de verificación de passkey no válida", + "txt_invalid_passkey_registration_response": "Respuesta de registro de passkey no válida", + "txt_passkey_browser_not_supported": "Este navegador no admite passkeys", + "txt_no_passkey_selected": "No se seleccionó ninguna passkey", + "txt_no_passkey_created": "No se creó ninguna passkey", + "txt_unsupported_encrypted_user_key": "Clave de cuenta cifrada no compatible", + "txt_passkey_verification_failed": "Error al verificar la passkey", + "txt_passkey_cannot_unlock_vault": "Esta passkey no puede desbloquear esta bóveda", + "txt_invalid_passkey_vault_key": "Clave de bóveda de passkey no válida", "txt_phone": "Teléfono", "txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña", "txt_please_input_master_password": "Por favor, introduzca contraseña maestra", diff --git a/webapp/src/lib/i18n/locales/ru.ts b/webapp/src/lib/i18n/locales/ru.ts index fa5037e..60daecd 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -644,6 +644,13 @@ const ru: Record = { "txt_account_passkey_not_found": "Passkey аккаунта не найдена", "txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ", "txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена", + "txt_account_passkey_direct_unlock_unavailable_title": "Прямая разблокировка недоступна", + "txt_account_passkey_direct_unlock_unavailable_message": "Эта passkey не вернула PRF-ключ, поэтому не может напрямую разблокировать хранилище. Ее все равно можно сохранить для входа в аккаунт; для разблокировки хранилища потребуется мастер-пароль.", + "txt_account_passkey_direct_unlock_unavailable_error": "Эта passkey не может напрямую разблокировать хранилище", + "txt_account_passkey_saved_login_only": "Passkey аккаунта сохранена только для входа", + "txt_account_passkey_not_saved": "Passkey аккаунта не сохранена", + "txt_save_login_only_passkey": "Сохранить только для входа", + "txt_do_not_save": "Не сохранять", "txt_add_account_passkey": "Добавить passkey аккаунта", "txt_delete_account_passkey": "Удалить passkey аккаунта", "txt_direct_unlock": "Прямая разблокировка", @@ -654,6 +661,17 @@ const ru: Record = { "txt_passkey_name": "Название passkey", "txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.", "txt_prf_not_supported": "PRF не поддерживается", + "txt_invalid_passkey_creation_options": "Недопустимые параметры создания passkey", + "txt_invalid_passkey_assertion_options": "Недопустимые параметры проверки passkey", + "txt_invalid_passkey_assertion_response": "Недопустимый ответ проверки passkey", + "txt_invalid_passkey_registration_response": "Недопустимый ответ регистрации passkey", + "txt_passkey_browser_not_supported": "Этот браузер не поддерживает passkeys", + "txt_no_passkey_selected": "Passkey не выбрана", + "txt_no_passkey_created": "Passkey не создана", + "txt_unsupported_encrypted_user_key": "Неподдерживаемый зашифрованный ключ аккаунта", + "txt_passkey_verification_failed": "Не удалось проверить passkey", + "txt_passkey_cannot_unlock_vault": "Эта passkey не может разблокировать это хранилище", + "txt_invalid_passkey_vault_key": "Недопустимый ключ хранилища passkey", "txt_phone": "Телефон", "txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль", "txt_please_input_master_password": "Пожалуйста, введите мастер-пароль", diff --git a/webapp/src/lib/i18n/locales/zh-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index d197cf8..04bef1b 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -644,16 +644,34 @@ const zhCN: Record = { "txt_account_passkey_not_found": "未找到账号通行密钥", "txt_account_passkey_prf_not_available": "这把通行密钥无法返回 PRF 密钥", "txt_account_passkey_direct_unlock_enabled": "已开启直接解锁", + "txt_account_passkey_direct_unlock_unavailable_title": "无法直接解锁", + "txt_account_passkey_direct_unlock_unavailable_message": "这把通行密钥没有返回 PRF 密钥,因此不能直接解锁密码库。你仍然可以把它保存为仅登录通行密钥;登录后需要输入主密码解锁。", + "txt_account_passkey_direct_unlock_unavailable_error": "这把通行密钥无法直接解锁密码库", + "txt_account_passkey_saved_login_only": "已保存为仅登录通行密钥", + "txt_account_passkey_not_saved": "通行密钥未保存", + "txt_save_login_only_passkey": "保存为仅登录", + "txt_do_not_save": "不保存", "txt_add_account_passkey": "添加账号通行密钥", "txt_delete_account_passkey": "删除账号通行密钥", "txt_direct_unlock": "直接解锁", "txt_enable_passkey_direct_unlock": "开启直接解锁", "txt_login_only": "仅登录", - "txt_login_with_passkey": "使用 Passkey 登录", + "txt_login_with_passkey": "使用通行密钥登录", "txt_no_account_passkeys": "暂无账号通行密钥", "txt_passkey_name": "通行密钥名称", - "txt_passkey_requires_master_password": "Passkey 已验证,请输入主密码解锁密码库。", + "txt_passkey_requires_master_password": "通行密钥已验证,请输入主密码解锁密码库。", "txt_prf_not_supported": "不支持 PRF", + "txt_invalid_passkey_creation_options": "通行密钥创建选项无效", + "txt_invalid_passkey_assertion_options": "通行密钥验证选项无效", + "txt_invalid_passkey_assertion_response": "通行密钥验证响应无效", + "txt_invalid_passkey_registration_response": "通行密钥注册响应无效", + "txt_passkey_browser_not_supported": "当前浏览器不支持通行密钥", + "txt_no_passkey_selected": "未选择通行密钥", + "txt_no_passkey_created": "未创建通行密钥", + "txt_unsupported_encrypted_user_key": "不支持的加密账户密钥", + "txt_passkey_verification_failed": "通行密钥验证失败", + "txt_passkey_cannot_unlock_vault": "这把通行密钥无法解锁此密码库", + "txt_invalid_passkey_vault_key": "通行密钥密码库密钥无效", "txt_phone": "电话", "txt_please_input_email_and_password": "请输入邮箱和密码", "txt_please_input_master_password": "请输入主密码", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index eb5be70..567bb7e 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -644,16 +644,34 @@ const zhTW: Record = { "txt_account_passkey_not_found": "未找到賬號通行密鑰", "txt_account_passkey_prf_not_available": "這把通行密鑰無法返回 PRF 密鑰", "txt_account_passkey_direct_unlock_enabled": "已開啟直接解鎖", + "txt_account_passkey_direct_unlock_unavailable_title": "無法直接解鎖", + "txt_account_passkey_direct_unlock_unavailable_message": "這把通行密鑰沒有返回 PRF 密鑰,因此不能直接解鎖密碼庫。你仍然可以把它保存為僅登錄通行密鑰;登錄後需要輸入主密碼解鎖。", + "txt_account_passkey_direct_unlock_unavailable_error": "這把通行密鑰無法直接解鎖密碼庫", + "txt_account_passkey_saved_login_only": "已保存為僅登錄通行密鑰", + "txt_account_passkey_not_saved": "通行密鑰未保存", + "txt_save_login_only_passkey": "保存為僅登錄", + "txt_do_not_save": "不保存", "txt_add_account_passkey": "添加賬號通行密鑰", "txt_delete_account_passkey": "刪除賬號通行密鑰", "txt_direct_unlock": "直接解鎖", "txt_enable_passkey_direct_unlock": "開啟直接解鎖", "txt_login_only": "僅登錄", - "txt_login_with_passkey": "使用 Passkey 登錄", + "txt_login_with_passkey": "使用通行密鑰登錄", "txt_no_account_passkeys": "暫無賬號通行密鑰", "txt_passkey_name": "通行密鑰名稱", - "txt_passkey_requires_master_password": "Passkey 已驗證,請輸入主密碼解鎖密碼庫。", + "txt_passkey_requires_master_password": "通行密鑰已驗證,請輸入主密碼解鎖密碼庫。", "txt_prf_not_supported": "不支持 PRF", + "txt_invalid_passkey_creation_options": "通行密鑰創建選項無效", + "txt_invalid_passkey_assertion_options": "通行密鑰驗證選項無效", + "txt_invalid_passkey_assertion_response": "通行密鑰驗證響應無效", + "txt_invalid_passkey_registration_response": "通行密鑰註冊響應無效", + "txt_passkey_browser_not_supported": "當前瀏覽器不支持通行密鑰", + "txt_no_passkey_selected": "未選擇通行密鑰", + "txt_no_passkey_created": "未創建通行密鑰", + "txt_unsupported_encrypted_user_key": "不支持的加密賬號密鑰", + "txt_passkey_verification_failed": "通行密鑰驗證失敗", + "txt_passkey_cannot_unlock_vault": "這把通行密鑰無法解鎖此密碼庫", + "txt_invalid_passkey_vault_key": "通行密鑰密碼庫密鑰無效", "txt_phone": "電話", "txt_please_input_email_and_password": "請輸入郵箱和密碼", "txt_please_input_master_password": "請輸入主密碼",