mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add passkey unlock functionality and improve related error handling
This commit is contained in:
@@ -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()}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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": "Пожалуйста, введите мастер-пароль",
|
||||||
|
|||||||
@@ -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": "通行密钥验证选项无效",
|
||||||
|
|||||||
@@ -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": "通行密鑰驗證選項無效",
|
||||||
|
|||||||
Reference in New Issue
Block a user