feat: add passkey unlock functionality and improve related error handling

This commit is contained in:
shuaiplus
2026-06-10 12:10:11 +08:00
parent 18e0396c0a
commit 19b96a7aca
9 changed files with 206 additions and 87 deletions
+28
View File
@@ -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() { async function handlePasskeyPasswordLogin() {
if (pendingAuthAction || !pendingPasskeyPassword) return; if (pendingAuthAction || !pendingPasskeyPassword) return;
if (!passkeyPassword) { if (!passkeyPassword) {
@@ -1720,6 +1747,7 @@ export default function App() {
onChangeUnlock={setUnlockPassword} onChangeUnlock={setUnlockPassword}
onSubmitLogin={() => void handleLogin()} onSubmitLogin={() => void handleLogin()}
onSubmitPasskey={() => void handlePasskeyLogin()} onSubmitPasskey={() => void handlePasskeyLogin()}
onSubmitPasskeyUnlock={() => void handlePasskeyUnlock()}
onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()} onSubmitPasskeyPassword={() => void handlePasskeyPasswordLogin()}
onSubmitRegister={() => void handleRegister()} onSubmitRegister={() => void handleRegister()}
onSubmitUnlock={() => void handleUnlock()} onSubmitUnlock={() => void handleUnlock()}
+14 -5
View File
@@ -40,6 +40,7 @@ interface AuthViewsProps {
onChangeUnlock: (password: string) => void; onChangeUnlock: (password: string) => void;
onSubmitLogin: () => void; onSubmitLogin: () => void;
onSubmitPasskey: () => void; onSubmitPasskey: () => void;
onSubmitPasskeyUnlock: () => void;
onSubmitPasskeyPassword: () => void; onSubmitPasskeyPassword: () => void;
onSubmitRegister: () => void; onSubmitRegister: () => void;
onSubmitUnlock: () => void; onSubmitUnlock: () => void;
@@ -122,12 +123,21 @@ export default function AuthViews(props: AuthViewsProps) {
{props.unlockPreparing ? ( {props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p> <p className="muted standalone-muted">{t('txt_loading')}</p>
) : null} ) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}> <button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" /> <Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')} {unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
</button> </button>
<button
type="button"
className="btn btn-secondary full"
onClick={props.onSubmitPasskeyUnlock}
disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}
>
<KeyRound size={16} className="btn-icon" />
{passkeyBusy ? t('txt_unlocking') : t('txt_unlock_with_passkey')}
</button>
<div className="or">{t('txt_or')}</div> <div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}> <button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy || passkeyBusy}>
<LogOut size={16} className="btn-icon" /> <LogOut size={16} className="btn-icon" />
{t('txt_log_out')} {t('txt_log_out')}
</button> </button>
@@ -291,17 +301,16 @@ export default function AuthViews(props: AuthViewsProps) {
: t('txt_show_password_hint')} : t('txt_show_password_hint')}
</button> </button>
</div> </div>
<button type="submit" className="btn btn-primary full" disabled={loginBusy}> <button type="submit" className="btn btn-primary full" disabled={loginBusy || passkeyBusy}>
<LogIn size={16} className="btn-icon" /> <LogIn size={16} className="btn-icon" />
{loginBusy ? t('txt_logging_in') : t('txt_log_in')} {loginBusy ? t('txt_logging_in') : t('txt_log_in')}
</button> </button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy || passkeyBusy}> <button type="button" className="btn btn-secondary full" onClick={props.onSubmitPasskey} disabled={loginBusy || passkeyBusy}>
<KeyRound size={16} className="btn-icon" /> <KeyRound size={16} className="btn-icon" />
{passkeyBusy ? t('txt_logging_in') : t('txt_login_with_passkey')} {passkeyBusy ? t('txt_logging_in') : t('txt_login_with_passkey')}
</button> </button>
<div className="or">{t('txt_or')}</div> <div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy}> <button type="button" className="btn btn-secondary full" onClick={props.onGotoRegister} disabled={loginBusy || passkeyBusy}>
<UserPlus size={16} className="btn-icon" /> <UserPlus size={16} className="btn-icon" />
{t('txt_create_account')} {t('txt_create_account')}
</button> </button>
+90 -22
View File
@@ -93,25 +93,98 @@ function credentialIdToBase64Url(id: BufferSource): string | null {
} }
} }
function buildPrfExtension( type PrfEvalInput = { first: Uint8Array };
function buildLegacyPrfExtension(salt: Uint8Array): Record<string, unknown> {
const evalInput: PrfEvalInput = { first: salt };
return {
prf: {
eval: evalInput,
},
};
}
function buildCredentialPrfExtension(
salt: Uint8Array, salt: Uint8Array,
credentialIds: Array<string | null | undefined> = [] credentialIds: Array<string | null | undefined>
): Record<string, unknown> { ): Record<string, unknown> {
const evalInput = { first: salt }; const evalInput = { first: salt };
const evalByCredential = credentialIds const evalByCredential = credentialIds
.filter((id): id is string => !!id) .filter((id): id is string => !!id)
.reduce<Record<string, typeof evalInput>>((out, id) => { .reduce<Record<string, PrfEvalInput>>((out, id) => {
out[id] = evalInput; out[id] = evalInput;
return out; return out;
}, {}); }, {});
if (!Object.keys(evalByCredential).length) return buildLegacyPrfExtension(salt);
return { return {
prf: { prf: {
eval: evalInput, evalByCredential,
...(Object.keys(evalByCredential).length ? { evalByCredential } : {}),
}, },
}; };
} }
function withPrfExtension(
options: PublicKeyCredentialRequestOptions,
extension: Record<string, unknown>
): 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<PublicKeyCredential> {
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[] { function prfCredentialIdsFromAllowCredentials(options: PublicKeyCredentialRequestOptions): string[] {
return (options.allowCredentials || []) return (options.allowCredentials || [])
.map((credential) => credentialIdToBase64Url(credential.id)) .map((credential) => credentialIdToBase64Url(credential.id))
@@ -178,15 +251,12 @@ export async function assertAccountPasskey(
throw new Error(t('txt_passkey_browser_not_supported')); throw new Error(t('txt_passkey_browser_not_supported'));
} }
const nativeOptions = cloneRequestOptions(response.options); const nativeOptions = cloneRequestOptions(response.options);
(nativeOptions as any).extensions = { const credential = await getPublicKeyCredentialWithPrf(
...((nativeOptions as any).extensions || {}), nativeOptions,
...buildPrfExtension(await getLoginWithPrfSalt(), prfCredentialIdsFromAllowCredentials(nativeOptions)), await getLoginWithPrfSalt(),
}; prfCredentialIdsFromAllowCredentials(nativeOptions)
const credential = await navigator.credentials.get({ publicKey: nativeOptions }); );
if (!(credential instanceof PublicKeyCredential)) { const prfResult = readPrfFirstResult(credential);
throw new Error(t('txt_no_passkey_selected'));
}
const prfResult = (credential.getClientExtensionResults() as any).prf?.results?.first;
return { return {
token: response.token, token: response.token,
deviceResponse: assertionRequest(credential), deviceResponse: assertionRequest(credential),
@@ -239,14 +309,12 @@ export async function buildAccountPasskeyPrfKeySet(
timeout: pending.createOptions?.timeout, timeout: pending.createOptions?.timeout,
userVerification: pending.createOptions?.authenticatorSelection?.userVerification, userVerification: pending.createOptions?.authenticatorSelection?.userVerification,
}; };
(assertionOptions as any).extensions = { const assertion = await getPublicKeyCredentialWithPrf(
...buildPrfExtension(await getLoginWithPrfSalt(), [credentialId]), assertionOptions,
}; await getLoginWithPrfSalt(),
const assertion = await navigator.credentials.get({ publicKey: assertionOptions }); [credentialId]
if (!(assertion instanceof PublicKeyCredential)) { );
throw new Error(t('txt_passkey_verification_failed')); const prfResult = readPrfFirstResult(assertion);
}
const prfResult = (assertion.getClientExtensionResults() as any).prf?.results?.first;
if (!prfResult) { if (!prfResult) {
throw new AccountPasskeyPrfUnavailableError(); throw new AccountPasskeyPrfUnavailableError();
} }
+5 -1
View File
@@ -420,7 +420,7 @@ export async function performPasswordLogin(
}; };
} }
export async function performPasskeyLogin(fallbackIterations: number): Promise<PasskeyLoginResult> { export async function performPasskeyLogin(fallbackIterations: number, expectedEmail?: string): Promise<PasskeyLoginResult> {
try { try {
const options = await getAccountPasskeyAssertionOptions(); const options = await getAccountPasskeyAssertionOptions();
const assertion = await assertAccountPasskey(options); const assertion = await assertAccountPasskey(options);
@@ -438,6 +438,10 @@ export async function performPasskeyLogin(fallbackIterations: number): Promise<P
if (!email) { if (!email) {
return { kind: 'error', message: t('txt_login_failed') }; return { kind: 'error', message: t('txt_login_failed') };
} }
const normalizedExpectedEmail = String(expectedEmail || '').trim().toLowerCase();
if (normalizedExpectedEmail && email !== normalizedExpectedEmail) {
return { kind: 'error', message: t('txt_passkey_not_for_locked_account') };
}
const prfOption = readPasskeyPrfOption(token); const prfOption = readPasskeyPrfOption(token);
if (prfOption && assertion.prfKey) { if (prfOption && assertion.prfKey) {
+2
View File
@@ -657,9 +657,11 @@ const en: Record<string, string> = {
"txt_enable_passkey_direct_unlock": "Enable direct unlock", "txt_enable_passkey_direct_unlock": "Enable direct unlock",
"txt_login_only": "Login only", "txt_login_only": "Login only",
"txt_login_with_passkey": "Log in with passkey", "txt_login_with_passkey": "Log in with passkey",
"txt_unlock_with_passkey": "Unlock with passkey",
"txt_no_account_passkeys": "No account passkeys", "txt_no_account_passkeys": "No account passkeys",
"txt_passkey_name": "Passkey name", "txt_passkey_name": "Passkey name",
"txt_passkey_requires_master_password": "Passkey verified. Enter your master password to unlock the vault.", "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_prf_not_supported": "PRF not supported",
"txt_invalid_passkey_creation_options": "Invalid passkey creation options", "txt_invalid_passkey_creation_options": "Invalid passkey creation options",
"txt_invalid_passkey_assertion_options": "Invalid passkey verification options", "txt_invalid_passkey_assertion_options": "Invalid passkey verification options",
+31 -29
View File
@@ -631,47 +631,49 @@ const es: Record<string, string> = {
"txt_passkey": "Clave de acceso", "txt_passkey": "Clave de acceso",
"txt_passkeys": "Claves de acceso", "txt_passkeys": "Claves de acceso",
"txt_passkey_created_at_value": "Creado el {value}", "txt_passkey_created_at_value": "Creado el {value}",
"txt_account_passkey": "Passkey de cuenta", "txt_account_passkey": "Clave de acceso de cuenta",
"txt_account_passkeys": "Passkeys de cuenta", "txt_account_passkeys": "Claves de acceso de cuenta",
"txt_account_passkey_mode": "Modo de desbloqueo", "txt_account_passkey_mode": "Modo de desbloqueo",
"txt_account_passkey_direct_unlock_mode": "Desbloqueo directo", "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_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 passkey y luego pide la contraseña maestra.", "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_name_placeholder": "Este dispositivo",
"txt_account_passkey_saved": "Passkey de cuenta guardada", "txt_account_passkey_saved": "Clave de acceso de cuenta guardada",
"txt_account_passkey_deleted": "Passkey de cuenta eliminada", "txt_account_passkey_deleted": "Clave de acceso de cuenta eliminada",
"txt_account_passkeys_load_failed": "Error al cargar passkeys de cuenta", "txt_account_passkeys_load_failed": "Error al cargar claves de acceso de cuenta",
"txt_account_passkey_not_found": "Passkey de cuenta no encontrada", "txt_account_passkey_not_found": "Clave de acceso de cuenta no encontrada",
"txt_account_passkey_prf_not_available": "Esta passkey no puede devolver una clave PRF", "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_enabled": "Desbloqueo directo activado",
"txt_account_passkey_direct_unlock_unavailable_title": "Desbloqueo directo no disponible", "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_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 passkey no puede desbloquear la bóveda directamente", "txt_account_passkey_direct_unlock_unavailable_error": "Esta clave de acceso 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_saved_login_only": "Clave de acceso de cuenta guardada solo para inicio de sesión",
"txt_account_passkey_not_saved": "La passkey de cuenta no se guardó", "txt_account_passkey_not_saved": "La clave de acceso de cuenta no se guardó",
"txt_save_login_only_passkey": "Guardar solo para inicio", "txt_save_login_only_passkey": "Guardar solo para inicio",
"txt_do_not_save": "No guardar", "txt_do_not_save": "No guardar",
"txt_add_account_passkey": "Añadir passkey de cuenta", "txt_add_account_passkey": "Añadir clave de acceso de cuenta",
"txt_delete_account_passkey": "Eliminar passkey de cuenta", "txt_delete_account_passkey": "Eliminar clave de acceso de cuenta",
"txt_direct_unlock": "Desbloqueo directo", "txt_direct_unlock": "Desbloqueo directo",
"txt_enable_passkey_direct_unlock": "Activar desbloqueo directo", "txt_enable_passkey_direct_unlock": "Activar desbloqueo directo",
"txt_login_only": "Solo inicio de sesión", "txt_login_only": "Solo inicio de sesión",
"txt_login_with_passkey": "Iniciar sesión con passkey", "txt_login_with_passkey": "Iniciar sesión con clave de acceso",
"txt_no_account_passkeys": "Sin passkeys de cuenta", "txt_unlock_with_passkey": "Desbloquear con clave de acceso",
"txt_passkey_name": "Nombre de passkey", "txt_no_account_passkeys": "Sin claves de acceso de cuenta",
"txt_passkey_requires_master_password": "Passkey verificada. Introduzca su contraseña maestra para desbloquear la bóveda.", "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_prf_not_supported": "PRF no compatible",
"txt_invalid_passkey_creation_options": "Opciones de creación de passkey no válidas", "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 passkey 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 passkey no válida", "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 passkey 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 passkeys", "txt_passkey_browser_not_supported": "Este navegador no admite claves de acceso",
"txt_no_passkey_selected": "No se seleccionó ninguna passkey", "txt_no_passkey_selected": "No se seleccionó ninguna clave de acceso",
"txt_no_passkey_created": "No se creó ninguna passkey", "txt_no_passkey_created": "No se creó ninguna clave de acceso",
"txt_unsupported_encrypted_user_key": "Clave de cuenta cifrada no compatible", "txt_unsupported_encrypted_user_key": "Clave de cuenta cifrada no compatible",
"txt_passkey_verification_failed": "Error al verificar la passkey", "txt_passkey_verification_failed": "Error al verificar la clave de acceso",
"txt_passkey_cannot_unlock_vault": "Esta passkey no puede desbloquear esta bóveda", "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 passkey no válida", "txt_invalid_passkey_vault_key": "Clave de bóveda de clave de acceso no válida",
"txt_phone": "Teléfono", "txt_phone": "Teléfono",
"txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña", "txt_please_input_email_and_password": "Por favor, introduzca correo y contraseña",
"txt_please_input_master_password": "Por favor, introduzca contraseña maestra", "txt_please_input_master_password": "Por favor, introduzca contraseña maestra",
+32 -30
View File
@@ -367,7 +367,7 @@ const ru: Record<string, string> = {
"txt_delete_all_invite_codes_active_inactive": "Удалить все пригласительные коды (активные/неактивные)?", "txt_delete_all_invite_codes_active_inactive": "Удалить все пригласительные коды (активные/неактивные)?",
"txt_delete_all_invites": "Удалить все приглашения", "txt_delete_all_invites": "Удалить все приглашения",
"txt_delete_item": "Удалить элемент", "txt_delete_item": "Удалить элемент",
"txt_delete_passkey": "Удалить пароль", "txt_delete_passkey": "Удалить ключ доступа",
"txt_delete_item_failed": "Удалить элемент не удалось", "txt_delete_item_failed": "Удалить элемент не удалось",
"txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент", "txt_permanent_delete_item_failed": "Не удалось окончательно удалить элемент",
"txt_delete_permanently": "Удалить навсегда", "txt_delete_permanently": "Удалить навсегда",
@@ -631,47 +631,49 @@ const ru: Record<string, string> = {
"txt_passkey": "Ключ доступа", "txt_passkey": "Ключ доступа",
"txt_passkeys": "Ключи доступа", "txt_passkeys": "Ключи доступа",
"txt_passkey_created_at_value": "Создано {value}", "txt_passkey_created_at_value": "Создано {value}",
"txt_account_passkey": "Passkey аккаунта", "txt_account_passkey": "Ключ доступа аккаунта",
"txt_account_passkeys": "Passkeys аккаунта", "txt_account_passkeys": "Ключи доступа аккаунта",
"txt_account_passkey_mode": "Режим разблокировки", "txt_account_passkey_mode": "Режим разблокировки",
"txt_account_passkey_direct_unlock_mode": "Прямая разблокировка", "txt_account_passkey_direct_unlock_mode": "Прямая разблокировка",
"txt_account_passkey_direct_unlock_help": "Разблокирует хранилище этой passkey, когда доступен PRF.", "txt_account_passkey_direct_unlock_help": "Разблокирует хранилище этим ключом доступа, когда доступен PRF.",
"txt_account_passkey_login_only_help": "Проверяет аккаунт passkey, затем запрашивает мастер-пароль.", "txt_account_passkey_login_only_help": "Проверяет аккаунт ключом доступа, затем запрашивает мастер-пароль.",
"txt_account_passkey_name_placeholder": "Это устройство", "txt_account_passkey_name_placeholder": "Это устройство",
"txt_account_passkey_saved": "Passkey аккаунта сохранена", "txt_account_passkey_saved": "Ключ доступа аккаунта сохранен",
"txt_account_passkey_deleted": "Passkey аккаунта удалена", "txt_account_passkey_deleted": "Ключ доступа аккаунта удален",
"txt_account_passkeys_load_failed": "Не удалось загрузить passkeys аккаунта", "txt_account_passkeys_load_failed": "Не удалось загрузить ключи доступа аккаунта",
"txt_account_passkey_not_found": "Passkey аккаунта не найдена", "txt_account_passkey_not_found": "Ключ доступа аккаунта не найден",
"txt_account_passkey_prf_not_available": "Эта passkey не может вернуть PRF-ключ", "txt_account_passkey_prf_not_available": "Этот ключ доступа не может вернуть PRF-ключ",
"txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена", "txt_account_passkey_direct_unlock_enabled": "Прямая разблокировка включена",
"txt_account_passkey_direct_unlock_unavailable_title": "Прямая разблокировка недоступна", "txt_account_passkey_direct_unlock_unavailable_title": "Прямая разблокировка недоступна",
"txt_account_passkey_direct_unlock_unavailable_message": "Эта passkey не вернула PRF-ключ, поэтому не может напрямую разблокировать хранилище. Ее все равно можно сохранить для входа в аккаунт; для разблокировки хранилища потребуется мастер-пароль.", "txt_account_passkey_direct_unlock_unavailable_message": "Этот ключ доступа не вернул PRF-ключ, поэтому не может напрямую разблокировать хранилище. Его все равно можно сохранить для входа в аккаунт; для разблокировки хранилища потребуется мастер-пароль.",
"txt_account_passkey_direct_unlock_unavailable_error": "Эта passkey не может напрямую разблокировать хранилище", "txt_account_passkey_direct_unlock_unavailable_error": "Этот ключ доступа не может напрямую разблокировать хранилище",
"txt_account_passkey_saved_login_only": "Passkey аккаунта сохранена только для входа", "txt_account_passkey_saved_login_only": "Ключ доступа аккаунта сохранен только для входа",
"txt_account_passkey_not_saved": "Passkey аккаунта не сохранена", "txt_account_passkey_not_saved": "Ключ доступа аккаунта не сохранен",
"txt_save_login_only_passkey": "Сохранить только для входа", "txt_save_login_only_passkey": "Сохранить только для входа",
"txt_do_not_save": "Не сохранять", "txt_do_not_save": "Не сохранять",
"txt_add_account_passkey": "Добавить passkey аккаунта", "txt_add_account_passkey": "Добавить ключ доступа аккаунта",
"txt_delete_account_passkey": "Удалить passkey аккаунта", "txt_delete_account_passkey": "Удалить ключ доступа аккаунта",
"txt_direct_unlock": "Прямая разблокировка", "txt_direct_unlock": "Прямая разблокировка",
"txt_enable_passkey_direct_unlock": "Включить прямую разблокировку", "txt_enable_passkey_direct_unlock": "Включить прямую разблокировку",
"txt_login_only": "Только вход", "txt_login_only": "Только вход",
"txt_login_with_passkey": "Войти с passkey", "txt_login_with_passkey": "Войти с ключом доступа",
"txt_no_account_passkeys": "Нет passkeys аккаунта", "txt_unlock_with_passkey": "Разблокировать ключом доступа",
"txt_passkey_name": "Название passkey", "txt_no_account_passkeys": "Нет ключей доступа аккаунта",
"txt_passkey_requires_master_password": "Passkey подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.", "txt_passkey_name": "Название ключа доступа",
"txt_passkey_requires_master_password": "Ключ доступа подтвержден. Введите мастер-пароль, чтобы разблокировать хранилище.",
"txt_passkey_not_for_locked_account": "Этот ключ доступа относится к другому аккаунту",
"txt_prf_not_supported": "PRF не поддерживается", "txt_prf_not_supported": "PRF не поддерживается",
"txt_invalid_passkey_creation_options": "Недопустимые параметры создания passkey", "txt_invalid_passkey_creation_options": "Недопустимые параметры создания ключа доступа",
"txt_invalid_passkey_assertion_options": "Недопустимые параметры проверки passkey", "txt_invalid_passkey_assertion_options": "Недопустимые параметры проверки ключа доступа",
"txt_invalid_passkey_assertion_response": "Недопустимый ответ проверки passkey", "txt_invalid_passkey_assertion_response": "Недопустимый ответ проверки ключа доступа",
"txt_invalid_passkey_registration_response": "Недопустимый ответ регистрации passkey", "txt_invalid_passkey_registration_response": "Недопустимый ответ регистрации ключа доступа",
"txt_passkey_browser_not_supported": "Этот браузер не поддерживает passkeys", "txt_passkey_browser_not_supported": "Этот браузер не поддерживает ключи доступа",
"txt_no_passkey_selected": "Passkey не выбрана", "txt_no_passkey_selected": "Ключ доступа не выбран",
"txt_no_passkey_created": "Passkey не создана", "txt_no_passkey_created": "Ключ доступа не создан",
"txt_unsupported_encrypted_user_key": "Неподдерживаемый зашифрованный ключ аккаунта", "txt_unsupported_encrypted_user_key": "Неподдерживаемый зашифрованный ключ аккаунта",
"txt_passkey_verification_failed": "Не удалось проверить passkey", "txt_passkey_verification_failed": "Не удалось проверить ключ доступа",
"txt_passkey_cannot_unlock_vault": "Эта passkey не может разблокировать это хранилище", "txt_passkey_cannot_unlock_vault": "Этот ключ доступа не может разблокировать это хранилище",
"txt_invalid_passkey_vault_key": "Недопустимый ключ хранилища passkey", "txt_invalid_passkey_vault_key": "Недопустимый ключ хранилища ключа доступа",
"txt_phone": "Телефон", "txt_phone": "Телефон",
"txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль", "txt_please_input_email_and_password": "Пожалуйста, введите адрес электронной почты и пароль",
"txt_please_input_master_password": "Пожалуйста, введите мастер-пароль", "txt_please_input_master_password": "Пожалуйста, введите мастер-пароль",
+2
View File
@@ -657,9 +657,11 @@ const zhCN: Record<string, string> = {
"txt_enable_passkey_direct_unlock": "开启直接解锁", "txt_enable_passkey_direct_unlock": "开启直接解锁",
"txt_login_only": "仅登录", "txt_login_only": "仅登录",
"txt_login_with_passkey": "使用通行密钥登录", "txt_login_with_passkey": "使用通行密钥登录",
"txt_unlock_with_passkey": "使用通行密钥解锁",
"txt_no_account_passkeys": "暂无账号通行密钥", "txt_no_account_passkeys": "暂无账号通行密钥",
"txt_passkey_name": "通行密钥名称", "txt_passkey_name": "通行密钥名称",
"txt_passkey_requires_master_password": "通行密钥已验证,请输入主密码解锁密码库。", "txt_passkey_requires_master_password": "通行密钥已验证,请输入主密码解锁密码库。",
"txt_passkey_not_for_locked_account": "这把通行密钥属于其他账号",
"txt_prf_not_supported": "不支持 PRF", "txt_prf_not_supported": "不支持 PRF",
"txt_invalid_passkey_creation_options": "通行密钥创建选项无效", "txt_invalid_passkey_creation_options": "通行密钥创建选项无效",
"txt_invalid_passkey_assertion_options": "通行密钥验证选项无效", "txt_invalid_passkey_assertion_options": "通行密钥验证选项无效",
+2
View File
@@ -657,9 +657,11 @@ const zhTW: Record<string, string> = {
"txt_enable_passkey_direct_unlock": "開啟直接解鎖", "txt_enable_passkey_direct_unlock": "開啟直接解鎖",
"txt_login_only": "僅登錄", "txt_login_only": "僅登錄",
"txt_login_with_passkey": "使用通行密鑰登錄", "txt_login_with_passkey": "使用通行密鑰登錄",
"txt_unlock_with_passkey": "使用通行密鑰解鎖",
"txt_no_account_passkeys": "暫無賬號通行密鑰", "txt_no_account_passkeys": "暫無賬號通行密鑰",
"txt_passkey_name": "通行密鑰名稱", "txt_passkey_name": "通行密鑰名稱",
"txt_passkey_requires_master_password": "通行密鑰已驗證,請輸入主密碼解鎖密碼庫。", "txt_passkey_requires_master_password": "通行密鑰已驗證,請輸入主密碼解鎖密碼庫。",
"txt_passkey_not_for_locked_account": "這把通行密鑰屬於其他賬號",
"txt_prf_not_supported": "不支持 PRF", "txt_prf_not_supported": "不支持 PRF",
"txt_invalid_passkey_creation_options": "通行密鑰創建選項無效", "txt_invalid_passkey_creation_options": "通行密鑰創建選項無效",
"txt_invalid_passkey_assertion_options": "通行密鑰驗證選項無效", "txt_invalid_passkey_assertion_options": "通行密鑰驗證選項無效",