From 19b96a7acae1a546f35aacb08582bc5b9573f157 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Wed, 10 Jun 2026 12:10:11 +0800 Subject: [PATCH] feat: add passkey unlock functionality and improve related error handling --- webapp/src/App.tsx | 28 +++++++ webapp/src/components/AuthViews.tsx | 19 +++-- webapp/src/lib/account-passkeys.ts | 112 +++++++++++++++++++++------ webapp/src/lib/app-auth.ts | 6 +- webapp/src/lib/i18n/locales/en.ts | 2 + webapp/src/lib/i18n/locales/es.ts | 60 +++++++------- webapp/src/lib/i18n/locales/ru.ts | 62 ++++++++------- webapp/src/lib/i18n/locales/zh-CN.ts | 2 + webapp/src/lib/i18n/locales/zh-TW.ts | 2 + 9 files changed, 206 insertions(+), 87 deletions(-) diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 1234eeb..54b753b 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -570,6 +570,33 @@ export default function App() { } } + async function handlePasskeyUnlock() { + if (pendingAuthAction) return; + const expectedEmail = (profile?.email || session?.email || '').trim().toLowerCase(); + if (!expectedEmail) return; + if (IS_DEMO_MODE) { + pushToast('warning', t('txt_demo_readonly_message')); + return; + } + setPendingAuthAction('passkey'); + try { + const result = await performPasskeyLogin(defaultKdfIterations, expectedEmail); + if (result.kind === 'success') { + await finalizeLogin(result.login, t('txt_unlocked')); + return; + } + if (result.kind === 'password') { + pushToast('error', t('txt_account_passkey_direct_unlock_unavailable_error')); + return; + } + pushToast('error', result.message || t('txt_unlock_failed_master_password_is_incorrect')); + } catch (error) { + pushToast('error', error instanceof Error ? error.message : t('txt_unlock_failed_master_password_is_incorrect')); + } finally { + setPendingAuthAction(null); + } + } + async function handlePasskeyPasswordLogin() { if (pendingAuthAction || !pendingPasskeyPassword) return; if (!passkeyPassword) { @@ -1720,6 +1747,7 @@ export default function App() { onChangeUnlock={setUnlockPassword} onSubmitLogin={() => void handleLogin()} onSubmitPasskey={() => void handlePasskeyLogin()} + onSubmitPasskeyUnlock={() => void handlePasskeyUnlock()} onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()} onSubmitRegister={() => void handleRegister()} onSubmitUnlock={() => void handleUnlock()} diff --git a/webapp/src/components/AuthViews.tsx b/webapp/src/components/AuthViews.tsx index b8c16ed..9587fff 100644 --- a/webapp/src/components/AuthViews.tsx +++ b/webapp/src/components/AuthViews.tsx @@ -40,6 +40,7 @@ interface AuthViewsProps { onChangeUnlock: (password: string) => void; onSubmitLogin: () => void; onSubmitPasskey: () => void; + onSubmitPasskeyUnlock: () => void; onSubmitPasskeyPassword: () => void; onSubmitRegister: () => void; onSubmitUnlock: () => void; @@ -122,12 +123,21 @@ export default function AuthViews(props: AuthViewsProps) { {props.unlockPreparing ? (

{t('txt_loading')}

) : null} - +
{t('txt_or')}
- @@ -291,17 +301,16 @@ export default function AuthViews(props: AuthViewsProps) { : t('txt_show_password_hint')} - -
{t('txt_or')}
{t('txt_or')}
- diff --git a/webapp/src/lib/account-passkeys.ts b/webapp/src/lib/account-passkeys.ts index a922b8b..ccab8a1 100644 --- a/webapp/src/lib/account-passkeys.ts +++ b/webapp/src/lib/account-passkeys.ts @@ -93,25 +93,98 @@ function credentialIdToBase64Url(id: BufferSource): string | null { } } -function buildPrfExtension( +type PrfEvalInput = { first: Uint8Array }; + +function buildLegacyPrfExtension(salt: Uint8Array): Record { + const evalInput: PrfEvalInput = { first: salt }; + return { + prf: { + eval: evalInput, + }, + }; +} + +function buildCredentialPrfExtension( salt: Uint8Array, - credentialIds: Array = [] + credentialIds: Array ): Record { const evalInput = { first: salt }; const evalByCredential = credentialIds .filter((id): id is string => !!id) - .reduce>((out, id) => { + .reduce>((out, id) => { out[id] = evalInput; return out; }, {}); + if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt); return { prf: { - eval: evalInput, - ...(Object.keys(evalByCredential).length ? { evalByCredential } : {}), + evalByCredential, }, }; } +function withPrfExtension( + options: PublicKeyCredentialRequestOptions, + extension: Record +): PublicKeyCredentialRequestOptions { + return { + ...options, + extensions: { + ...((options as any).extensions || {}), + ...extension, + } as any, + }; +} + +function readPrfFirstResult(credential: PublicKeyCredential): ArrayBuffer | undefined { + const result = (credential.getClientExtensionResults() as any).prf?.results?.first; + return result instanceof ArrayBuffer ? result : undefined; +} + +function hasPrfExtensionResult(credential: PublicKeyCredential): boolean { + return Object.prototype.hasOwnProperty.call(credential.getClientExtensionResults() as any, 'prf'); +} + +function shouldRetryWithLegacyPrf(error: unknown): boolean { + const name = error instanceof DOMException || error instanceof Error ? error.name : ''; + return name === 'NotSupportedError' || name === 'SyntaxError' || name === 'TypeError'; +} + +async function getPublicKeyCredentialWithPrf( + options: PublicKeyCredentialRequestOptions, + salt: Uint8Array, + credentialIds: string[] = [] +): Promise { + const attempts = credentialIds.length + ? [ + buildCredentialPrfExtension(salt, credentialIds), + buildLegacyPrfExtension(salt), + ] + : [buildLegacyPrfExtension(salt)]; + let lastCredential: PublicKeyCredential | null = null; + for (let index = 0; index < attempts.length; index += 1) { + try { + const credential = await navigator.credentials.get({ + publicKey: withPrfExtension(options, attempts[index]), + }); + if (!(credential instanceof PublicKeyCredential)) { + throw new Error(t('txt_no_passkey_selected')); + } + lastCredential = credential; + if (readPrfFirstResult(credential) || hasPrfExtensionResult(credential) || index === attempts.length - 1) { + return credential; + } + } catch (error) { + if (index === attempts.length - 1 || !shouldRetryWithLegacyPrf(error)) { + if (lastCredential) return lastCredential; + throw error; + } + } + } + if (lastCredential) return lastCredential; + throw new Error(t('txt_no_passkey_selected')); +} + function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] { return (options.allowCredentials || []) .map((credential) => credentialIdToBase64Url(credential.id)) @@ -178,15 +251,12 @@ export async function assertAccountPasskey( throw new Error(t('txt_passkey_browser_not_supported')); } const nativeOptions = cloneRequestOptions(response.options); - (nativeOptions as any).extensions = { - ...((nativeOptions as any).extensions || {}), - ...buildPrfExtension(await getLoginWithPrfSalt(), prfCredentialIdsFromAllowCredentials(nativeOptions)), - }; - const credential = await navigator.credentials.get({ publicKey: nativeOptions }); - if (!(credential instanceof PublicKeyCredential)) { - throw new Error(t('txt_no_passkey_selected')); - } - const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first; + const credential = await getPublicKeyCredentialWithPrf( + nativeOptions, + await getLoginWithPrfSalt(), + prfCredentialIdsFromAllowCredentials(nativeOptions) + ); + const prfResult = readPrfFirstResult(credential); return { token: response.token, deviceResponse: assertionRequest(credential), @@ -239,14 +309,12 @@ export async function buildAccountPasskeyPrfKeySet( timeout: pending.createOptions?.timeout, userVerification: pending.createOptions?.authenticatorSelection?.userVerification, }; - (assertionOptions as any).extensions = { - ...buildPrfExtension(await getLoginWithPrfSalt(), [credentialId]), - }; - const assertion = await navigator.credentials.get({ publicKey: assertionOptions }); - if (!(assertion instanceof PublicKeyCredential)) { - throw new Error(t('txt_passkey_verification_failed')); - } - const prfResult = (assertion.getClientExtensionResults() as any).prf?.results?.first; + const assertion = await getPublicKeyCredentialWithPrf( + assertionOptions, + await getLoginWithPrfSalt(), + [credentialId] + ); + const prfResult = readPrfFirstResult(assertion); if (!prfResult) { throw new AccountPasskeyPrfUnavailableError(); } diff --git a/webapp/src/lib/app-auth.ts b/webapp/src/lib/app-auth.ts index 2f16332..90af556 100644 --- a/webapp/src/lib/app-auth.ts +++ b/webapp/src/lib/app-auth.ts @@ -420,7 +420,7 @@ export async function performPasswordLogin( }; } -export async function performPasskeyLogin(fallbackIterations: number): Promise { +export async function performPasskeyLogin(fallbackIterations: number, expectedEmail?: string): Promise { try { const options = await getAccountPasskeyAssertionOptions(); const assertion = await assertAccountPasskey(options); @@ -438,6 +438,10 @@ export async function performPasskeyLogin(fallbackIterations: number): Promise

= { "txt_enable_passkey_direct_unlock": "Enable direct unlock", "txt_login_only": "Login only", "txt_login_with_passkey": "Log in with passkey", + "txt_unlock_with_passkey": "Unlock with passkey", "txt_no_account_passkeys": "No account passkeys", "txt_passkey_name": "Passkey name", "txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.", + "txt_passkey_not_for_locked_account": "This passkey is for a different account", "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", diff --git a/webapp/src/lib/i18n/locales/es.ts b/webapp/src/lib/i18n/locales/es.ts index d643de6..826b55a 100644 --- a/webapp/src/lib/i18n/locales/es.ts +++ b/webapp/src/lib/i18n/locales/es.ts @@ -631,47 +631,49 @@ const es: Record = { "txt_passkey": "Clave de acceso", "txt_passkeys": "Claves de acceso", "txt_passkey_created_at_value": "Creado el {value}", - "txt_account_passkey": "Passkey de cuenta", - "txt_account_passkeys": "Passkeys de cuenta", + "txt_account_passkey": "Clave de acceso de cuenta", + "txt_account_passkeys": "Claves de acceso de cuenta", "txt_account_passkey_mode": "Modo de desbloqueo", "txt_account_passkey_direct_unlock_mode": "Desbloqueo directo", - "txt_account_passkey_direct_unlock_help": "Desbloquea la bóveda con esta passkey cuando PRF está disponible.", - "txt_account_passkey_login_only_help": "Verifica la cuenta con passkey y luego pide la contraseña maestra.", + "txt_account_passkey_direct_unlock_help": "Desbloquea la bóveda con esta clave de acceso cuando PRF está disponible.", + "txt_account_passkey_login_only_help": "Verifica la cuenta con una clave de acceso y luego pide la contraseña maestra.", "txt_account_passkey_name_placeholder": "Este dispositivo", - "txt_account_passkey_saved": "Passkey de cuenta guardada", - "txt_account_passkey_deleted": "Passkey de cuenta eliminada", - "txt_account_passkeys_load_failed": "Error al cargar passkeys de cuenta", - "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_saved": "Clave de acceso de cuenta guardada", + "txt_account_passkey_deleted": "Clave de acceso de cuenta eliminada", + "txt_account_passkeys_load_failed": "Error al cargar claves de acceso de cuenta", + "txt_account_passkey_not_found": "Clave de acceso de cuenta no encontrada", + "txt_account_passkey_prf_not_available": "Esta clave de acceso 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_account_passkey_direct_unlock_unavailable_message": "Esta clave de acceso 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 clave de acceso no puede desbloquear la bóveda directamente", + "txt_account_passkey_saved_login_only": "Clave de acceso de cuenta guardada solo para inicio de sesión", + "txt_account_passkey_not_saved": "La clave de acceso 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_add_account_passkey": "Añadir clave de acceso de cuenta", + "txt_delete_account_passkey": "Eliminar clave de acceso de cuenta", "txt_direct_unlock": "Desbloqueo directo", "txt_enable_passkey_direct_unlock": "Activar desbloqueo directo", "txt_login_only": "Solo inicio de sesión", - "txt_login_with_passkey": "Iniciar sesión con passkey", - "txt_no_account_passkeys": "Sin passkeys de cuenta", - "txt_passkey_name": "Nombre de passkey", - "txt_passkey_requires_master_password": "Passkey verificada. Introduzca su contraseña maestra para desbloquear la bóveda.", + "txt_login_with_passkey": "Iniciar sesión con clave de acceso", + "txt_unlock_with_passkey": "Desbloquear con clave de acceso", + "txt_no_account_passkeys": "Sin claves de acceso de cuenta", + "txt_passkey_name": "Nombre de la clave de acceso", + "txt_passkey_requires_master_password": "Clave de acceso verificada. Introduzca su contraseña maestra para desbloquear la bóveda.", + "txt_passkey_not_for_locked_account": "Esta clave de acceso pertenece a otra cuenta", "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_invalid_passkey_creation_options": "Opciones de creación de clave de acceso no válidas", + "txt_invalid_passkey_assertion_options": "Opciones de verificación de clave de acceso no válidas", + "txt_invalid_passkey_assertion_response": "Respuesta de verificación de clave de acceso no válida", + "txt_invalid_passkey_registration_response": "Respuesta de registro de clave de acceso no válida", + "txt_passkey_browser_not_supported": "Este navegador no admite claves de acceso", + "txt_no_passkey_selected": "No se seleccionó ninguna clave de acceso", + "txt_no_passkey_created": "No se creó ninguna clave de acceso", "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_passkey_verification_failed": "Error al verificar la clave de acceso", + "txt_passkey_cannot_unlock_vault": "Esta clave de acceso no puede desbloquear esta bóveda", + "txt_invalid_passkey_vault_key": "Clave de bóveda de clave de acceso 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 60daecd..484bc0e 100644 --- a/webapp/src/lib/i18n/locales/ru.ts +++ b/webapp/src/lib/i18n/locales/ru.ts @@ -367,7 +367,7 @@ const ru: Record = { "txt_delete_all_invite_codes_active_inactive": "Удалить все пригласительные коды (активные/неактивные)?", "txt_delete_all_invites": "Удалить все приглашения", "txt_delete_item": "Удалить элемент", - "txt_delete_passkey": "Удалить пароль", + "txt_delete_passkey": "Удалить ключ доступа", "txt_delete_item_failed": "Удалить элемент не удалось", "txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент", "txt_delete_permanently": "Удалить навсегда", @@ -631,47 +631,49 @@ const ru: Record = { "txt_passkey": "Ключ доступа", "txt_passkeys": "Ключи доступа", "txt_passkey_created_at_value": "Создано {value}", - "txt_account_passkey": "Passkey аккаунта", - "txt_account_passkeys": "Passkeys аккаунта", + "txt_account_passkey": "Ключ доступа аккаунта", + "txt_account_passkeys": "Ключи доступа аккаунта", "txt_account_passkey_mode": "Режим разблокировки", "txt_account_passkey_direct_unlock_mode": "Прямая разблокировка", - "txt_account_passkey_direct_unlock_help": "Разблокирует хранилище этой passkey, когда доступен PRF.", - "txt_account_passkey_login_only_help": "Проверяет аккаунт passkey, затем запрашивает мастер-пароль.", + "txt_account_passkey_direct_unlock_help": "Разблокирует хранилище этим ключом доступа, когда доступен PRF.", + "txt_account_passkey_login_only_help": "Проверяет аккаунт ключом доступа, затем запрашивает мастер-пароль.", "txt_account_passkey_name_placeholder": "Это устройство", - "txt_account_passkey_saved": "Passkey аккаунта сохранена", - "txt_account_passkey_deleted": "Passkey аккаунта удалена", - "txt_account_passkeys_load_failed": "Не удалось загрузить passkeys аккаунта", - "txt_account_passkey_not_found": "Passkey аккаунта не найдена", - "txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ", + "txt_account_passkey_saved": "Ключ доступа аккаунта сохранен", + "txt_account_passkey_deleted": "Ключ доступа аккаунта удален", + "txt_account_passkeys_load_failed": "Не удалось загрузить ключи доступа аккаунта", + "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": "Эта passkey не вернула PRF-ключ, поэтому не может напрямую разблокировать хранилище. Ее все равно можно сохранить для входа в аккаунт; для разблокировки хранилища потребуется мастер-пароль.", - "txt_account_passkey_direct_unlock_unavailable_error": "Эта passkey не может напрямую разблокировать хранилище", - "txt_account_passkey_saved_login_only": "Passkey аккаунта сохранена только для входа", - "txt_account_passkey_not_saved": "Passkey аккаунта не сохранена", + "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": "Добавить passkey аккаунта", - "txt_delete_account_passkey": "Удалить passkey аккаунта", + "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_no_account_passkeys": "Нет passkeys аккаунта", - "txt_passkey_name": "Название passkey", - "txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.", + "txt_login_with_passkey": "Войти с ключом доступа", + "txt_unlock_with_passkey": "Разблокировать ключом доступа", + "txt_no_account_passkeys": "Нет ключей доступа аккаунта", + "txt_passkey_name": "Название ключа доступа", + "txt_passkey_requires_master_password": "Ключ доступа подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.", + "txt_passkey_not_for_locked_account": "Этот ключ доступа относится к другому аккаунту", "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_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": "Не удалось проверить passkey", - "txt_passkey_cannot_unlock_vault": "Эта passkey не может разблокировать это хранилище", - "txt_invalid_passkey_vault_key": "Недопустимый ключ хранилища passkey", + "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-CN.ts b/webapp/src/lib/i18n/locales/zh-CN.ts index 04bef1b..1bc7d88 100644 --- a/webapp/src/lib/i18n/locales/zh-CN.ts +++ b/webapp/src/lib/i18n/locales/zh-CN.ts @@ -657,9 +657,11 @@ const zhCN: Record = { "txt_enable_passkey_direct_unlock": "开启直接解锁", "txt_login_only": "仅登录", "txt_login_with_passkey": "使用通行密钥登录", + "txt_unlock_with_passkey": "使用通行密钥解锁", "txt_no_account_passkeys": "暂无账号通行密钥", "txt_passkey_name": "通行密钥名称", "txt_passkey_requires_master_password": "通行密钥已验证,请输入主密码解锁密码库。", + "txt_passkey_not_for_locked_account": "这把通行密钥属于其他账号", "txt_prf_not_supported": "不支持 PRF", "txt_invalid_passkey_creation_options": "通行密钥创建选项无效", "txt_invalid_passkey_assertion_options": "通行密钥验证选项无效", diff --git a/webapp/src/lib/i18n/locales/zh-TW.ts b/webapp/src/lib/i18n/locales/zh-TW.ts index 567bb7e..f12a54f 100644 --- a/webapp/src/lib/i18n/locales/zh-TW.ts +++ b/webapp/src/lib/i18n/locales/zh-TW.ts @@ -657,9 +657,11 @@ const zhTW: Record = { "txt_enable_passkey_direct_unlock": "開啟直接解鎖", "txt_login_only": "僅登錄", "txt_login_with_passkey": "使用通行密鑰登錄", + "txt_unlock_with_passkey": "使用通行密鑰解鎖", "txt_no_account_passkeys": "暫無賬號通行密鑰", "txt_passkey_name": "通行密鑰名稱", "txt_passkey_requires_master_password": "通行密鑰已驗證,請輸入主密碼解鎖密碼庫。", + "txt_passkey_not_for_locked_account": "這把通行密鑰屬於其他賬號", "txt_prf_not_supported": "不支持 PRF", "txt_invalid_passkey_creation_options": "通行密鑰創建選項無效", "txt_invalid_passkey_assertion_options": "通行密鑰驗證選項無效",