feat: offer to restore the saved account list on a new device

Part 3 of 3 of the account archive (#1198). Signing in on a browser that
knows no other account, with a list saved for that account, opens a dialog
asking for the backup password and restores the list as Settings does.
Not now hides the offer for that account in this browser; the restore
stays in Settings.
This commit is contained in:
lucamzanon
2026-10-26 12:23:18 +01:00
parent d76a472022
commit 2f8c6e13bf
32 changed files with 350 additions and 11 deletions
+4
View File
@@ -17,6 +17,8 @@ import { PluginAppEvents } from "@/components/providers/plugin-app-events";
import { PluginOAuthCallbackListener } from "@/components/providers/plugin-oauth-callback-listener";
import { PWAInstallPrompt } from "@/components/pwa-install-prompt";
import { PushNotificationPrompt } from "@/components/push-notification-prompt";
import { AccountArchiveRestorePrompt } from "@/components/account-archive-prompt";
import { IS_LITE } from "@/lib/lite";
import { setRequestLocale } from "next-intl/server";
import { locales } from "@/i18n/routing";
import { generateLiteLocaleParams } from "@/lib/lite-static-params";
@@ -67,6 +69,8 @@ export default async function LocaleLayout({
<PluginAppEvents />
<PWAInstallPrompt />
<PushNotificationPrompt />
{/* Lite is a static bundle: no server keeps an archive there. */}
{!IS_LITE && <AccountArchiveRestorePrompt />}
</ProtocolLaunchHandlerProvider>
</TourProvider>
</EmbeddedBridgeProvider>
@@ -0,0 +1,92 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { AccountArchiveRestorePrompt } from '../account-archive-prompt';
import { encryptArchive } from '@/lib/account-archive';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), stored: null as unknown }));
vi.mock('next-intl', () => {
const t = (key: string, values?: Record<string, unknown>) => values ? `${key} ${JSON.stringify(values)}` : key;
return { useTranslations: () => t, useLocale: () => 'en' };
});
vi.mock('@/lib/browser-navigation', async (importOriginal) => ({
...(await importOriginal<typeof import('@/lib/browser-navigation')>()),
apiFetch: mocks.apiFetch,
}));
const owner = { username: 'owner@x.test', serverUrl: 'https://mail.x.test' };
const account = {
id: 'owner@x.test@mail.x.test', label: 'Work', ...owner, authMode: 'basic' as const, rememberMe: true, cookieSlot: 0,
displayName: 'Work', email: owner.username, isConnected: true, hasError: false, lastLoginAt: 1, avatarColor: '#2563eb', isDefault: true,
};
const other = { username: 'other@y.test', serverUrl: 'https://mail.y.test', label: 'Home', avatarColor: '#16a34a' };
const PASSWORD = 'a long backup password';
beforeEach(async () => {
localStorage.clear();
mocks.stored = await encryptArchive(owner, [{ ...owner, label: 'Work', avatarColor: '#2563eb' }, other], PASSWORD);
// Only the archive answers; anything else the app asks for (the policy, say) is not there.
mocks.apiFetch.mockReset().mockImplementation(async (url: string) => url === '/api/account-archive'
? new Response(JSON.stringify({ envelope: mocks.stored }), { status: 200 })
: new Response('{}', { status: 404 }));
usePolicyStore.setState(s => ({ policy: { ...s.policy, features: { ...s.policy.features, accountArchiveEnabled: true } } }));
useAuthStore.setState({ ...owner, isAuthenticated: true, isDemoMode: false });
useAccountStore.setState({ accounts: [account] as never, activeAccountId: account.id, defaultAccountId: account.id });
});
const archiveCalls = () => mocks.apiFetch.mock.calls.filter(([url]) => url === '/api/account-archive').length;
const fill = (value: string) => fireEvent.change(screen.getByLabelText('password'), { target: { value } });
describe('AccountArchiveRestorePrompt', () => {
it('offers the saved list on a browser with only the signed-in account, and restores it', { timeout: 20_000 }, async () => {
render(<AccountArchiveRestorePrompt />);
expect(await screen.findByRole('dialog')).toHaveTextContent('prompt_title');
fill('not the password');
fireEvent.submit(screen.getByLabelText('password').closest('form')!);
expect(await screen.findByRole('alert', {}, { timeout: 5000 })).toHaveTextContent('errors.unlock_failed');
fill(PASSWORD);
fireEvent.submit(screen.getByLabelText('password').closest('form')!);
await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('restored'), { timeout: 5000 });
expect(useAccountStore.getState().accounts.map(a => a.username)).toEqual([owner.username, other.username]);
fireEvent.click(screen.getByRole('button', { name: 'prompt_done' }));
expect(screen.queryByRole('dialog')).toBeNull();
});
it('does not ask again in this browser once declined', async () => {
const { unmount } = render(<AccountArchiveRestorePrompt />);
fireEvent.click(await screen.findByRole('button', { name: 'prompt_not_now' }));
expect(screen.queryByRole('dialog')).toBeNull();
unmount();
render(<AccountArchiveRestorePrompt />);
await act(async () => { await Promise.resolve(); });
expect(screen.queryByRole('dialog')).toBeNull();
expect(archiveCalls()).toBe(1);
});
it('stays quiet when this browser already holds other accounts', async () => {
useAccountStore.setState({ accounts: [account, { ...account, id: 'b', username: 'b@x.test' }] as never });
render(<AccountArchiveRestorePrompt />);
await act(async () => { await Promise.resolve(); });
expect(archiveCalls()).toBe(0);
});
it('stays quiet when nothing is saved or the feature is off', async () => {
mocks.stored = null;
const { unmount } = render(<AccountArchiveRestorePrompt />);
await waitFor(() => expect(archiveCalls()).toBe(1));
expect(screen.queryByRole('dialog')).toBeNull();
unmount();
mocks.apiFetch.mockClear();
usePolicyStore.setState(s => ({ policy: { ...s.policy, features: { ...s.policy.features, accountArchiveEnabled: false } } }));
render(<AccountArchiveRestorePrompt />);
await act(async () => { await Promise.resolve(); });
expect(archiveCalls()).toBe(0);
});
});
+122
View File
@@ -0,0 +1,122 @@
"use client";
import { useCallback, useEffect, useId, useState } from 'react';
import { createPortal } from 'react-dom';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { useFocusTrap } from '@/hooks/use-focus-trap';
import { useConfig } from '@/hooks/use-config';
import { useAccountStore } from '@/stores/account-store';
import { useAuthStore } from '@/stores/auth-store';
import { usePolicyStore } from '@/stores/policy-store';
import { archiveIdentity, decryptArchive, type ArchiveOwner } from '@/lib/account-archive';
import { fetchArchive, restoreArchive } from '@/lib/account-archive-client';
import { toUnicodeEmail } from '@/lib/idn';
import { archiveErrorText, restoreSummary } from '@/components/settings/account-archive-settings';
const promptKey = (owner: ArchiveOwner) => `account-archive-prompt:${archiveIdentity(owner)}`;
function wasOffered(owner: ArchiveOwner): boolean {
try { return localStorage.getItem(promptKey(owner)) !== null; } catch { return false; }
}
function markOffered(owner: ArchiveOwner) {
try { localStorage.setItem(promptKey(owner), 'offered'); } catch { /* storage unavailable */ }
}
/**
* On a browser that knows only the account just signed in to, offers once to
* bring back the account list saved for it. Declining leaves the restore in
* Settings; the offer does not come back for that account in this browser.
*/
export function AccountArchiveRestorePrompt() {
const enabled = usePolicyStore(s => s.isFeatureEnabled('accountArchiveEnabled'));
const signedIn = useAuthStore(s => s.isAuthenticated && !s.isDemoMode);
const username = useAuthStore(s => s.username);
const serverUrl = useAuthStore(s => s.serverUrl);
// A browser that already holds other accounts is not a new device.
const onlyAccount = useAccountStore(s => s.accounts.length === 1);
const [owner, setOwner] = useState<ArchiveOwner | null>(null);
useEffect(() => {
// An open offer stays open while the restore adds accounts; signing in to
// another account instead hides it (the check before rendering).
if (!enabled || !signedIn || !onlyAccount || !username || !serverUrl) return;
const candidate = { username, serverUrl };
if (wasOffered(candidate)) return;
let cancelled = false;
fetchArchive(candidate)
.then(envelope => { if (!cancelled && envelope) setOwner(candidate); })
.catch(() => { /* An optional lookup must not get in the way of mail. */ });
return () => { cancelled = true; };
}, [enabled, signedIn, onlyAccount, username, serverUrl]);
const close = useCallback(() => {
if (owner) markOffered(owner);
setOwner(null);
}, [owner]);
if (!owner || owner.username !== username || owner.serverUrl !== serverUrl) return null;
return createPortal(<RestoreDialog owner={owner} close={close} />, document.body);
}
function RestoreDialog({ owner, close }: { owner: ArchiveOwner; close: () => void }) {
const t = useTranslations('settings.account.backup');
const id = useId();
const { rememberMeEnabled } = useConfig();
const [password, setPassword] = useState('');
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState<{ error: boolean; text: string } | null>(null);
const done = message !== null && !message.error;
const onEscape = useCallback(() => { if (!busy) close(); }, [busy, close]);
const ref = useFocusTrap({ isActive: true, onEscape, restoreFocus: true });
const restore = async () => {
setBusy(true); setMessage(null);
try {
const envelope = await fetchArchive(owner);
if (!envelope) throw new Error('storage_failed');
const result = await restoreArchive(await decryptArchive(owner, envelope, password), rememberMeEnabled);
setPassword('');
setMessage({ error: false, text: restoreSummary(t, result) });
} catch (err) {
setMessage({ error: true, text: archiveErrorText(t, err) });
} finally { setBusy(false); }
};
return (
<div className="fixed inset-0 z-[70] flex items-center justify-center bg-black/50 p-4">
<div
ref={ref} role="dialog" aria-modal="true" aria-labelledby={`${id}-title`} aria-describedby={`${id}-description`}
className="max-h-[90dvh] w-full max-w-md space-y-4 overflow-y-auto rounded-lg border border-border bg-background p-6 shadow-xl"
>
<h2 id={`${id}-title`} className="text-lg font-semibold">{t('prompt_title')}</h2>
<p id={`${id}-description`} className="text-sm text-muted-foreground">
{t('prompt_description', { account: toUnicodeEmail(owner.username) })}
</p>
{done ? (
<>
<p role="status" className="text-sm text-foreground">{message?.text}</p>
<div className="flex justify-end">
<Button type="button" onClick={close}>{t('prompt_done')}</Button>
</div>
</>
) : (
<form className="space-y-3" onSubmit={e => { e.preventDefault(); void restore(); }}>
<label className="block text-sm font-medium" htmlFor={`${id}-password`}>{t('password')}</label>
<Input
id={`${id}-password`} type="password" value={password} required autoFocus autoComplete="current-password"
aria-describedby={`${id}-hint`} onChange={e => setPassword(e.target.value)}
/>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">{t('restore_hint')}</p>
{message && <p role="alert" className="text-sm text-destructive">{message.text}</p>}
<div className="flex flex-wrap justify-end gap-2">
<Button type="button" variant="ghost" disabled={busy} onClick={close}>{t('prompt_not_now')}</Button>
<Button type="submit" disabled={busy}>{t('restore')}</Button>
</div>
</form>
)}
</div>
</div>
);
}
@@ -14,6 +14,24 @@ import { SettingsSection } from './settings-section';
const KNOWN_ERRORS = ['mismatch', 'password_length', 'unlock_failed', 'owner_signin_required', 'https_required'];
type BackupT = ReturnType<typeof useTranslations>;
/** The text for a failed save, restore or delete. */
export function archiveErrorText(t: BackupT, err: unknown): string {
const code = err instanceof Error ? err.message : '';
return t(`errors.${KNOWN_ERRORS.includes(code) ? code : 'storage_failed'}`);
}
/** What a restore did, in one status line. */
export function restoreSummary(t: BackupT, result: Awaited<ReturnType<typeof restoreArchive>>): string {
return [
t(result.waiting ? 'restored' : 'restored_signed_in', result),
result.signedIn ? t('signed_in', { count: result.signedIn }) : '',
result.signInFailed ? t('sign_in_failed', { count: result.signInFailed }) : '',
result.skipped ? t('skipped', { count: result.skipped }) : '',
].join(' ').trim();
}
/** Save this browser's account list, encrypted, under the active account; restore it on another device. */
export function AccountArchiveSettings() {
const t = useTranslations('settings.account.backup');
@@ -53,10 +71,7 @@ export function AccountArchiveSettings() {
open(null);
setMessage({ error: false, text });
}
catch (err) {
const code = err instanceof Error ? err.message : '';
setMessage({ error: true, text: t(`errors.${KNOWN_ERRORS.includes(code) ? code : 'storage_failed'}`) });
} finally { setBusy(false); }
catch (err) { setMessage({ error: true, text: archiveErrorText(t, err) }); } finally { setBusy(false); }
};
const save = () => run(async () => {
@@ -70,13 +85,7 @@ export function AccountArchiveSettings() {
const restore = () => run(async () => {
const envelope = await fetchArchive(owner);
if (!envelope) { setSaved(false); throw new Error('storage_failed'); }
const result = await restoreArchive(await decryptArchive(owner, envelope, password), rememberMeEnabled);
return [
t(result.waiting ? 'restored' : 'restored_signed_in', result),
result.signedIn ? t('signed_in', { count: result.signedIn }) : '',
result.signInFailed ? t('sign_in_failed', { count: result.signInFailed }) : '',
result.skipped ? t('skipped', { count: result.skipped }) : '',
].join(' ').trim();
return restoreSummary(t, await restoreArchive(await decryptArchive(owner, envelope, password), rememberMeEnabled));
});
const remove = () => {
if (!window.confirm(t('delete_confirm'))) return;
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Bei {count, plural, one {# Konto} other {# Konten}} mit dem gespeicherten Passwort angemeldet.",
"sign_in_failed": "{count, plural, one {# gespeichertes Passwort wurde} other {# gespeicherte Passwörter wurden}} abgelehnt: Melden Sie sich bei diesen Konten über die Kontoliste an.",
"deleted": "Gespeicherte Liste gelöscht.",
"prompt_title": "Weitere Konten wiederherstellen?",
"prompt_description": "Für {account} ist eine Kontenliste gespeichert. Gib das Backup-Passwort ein, um die Konten in diesem Browser hinzuzufügen, oder mach es später in den Einstellungen.",
"prompt_not_now": "Nicht jetzt",
"prompt_done": "Fertig",
"errors": {
"mismatch": "Die Passwörter stimmen nicht überein.",
"password_length": "Verwenden Sie mindestens 10 Zeichen.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Sesión iniciada en {count, plural, one {# cuenta} other {# cuentas}} con la contraseña guardada.",
"sign_in_failed": "{count, plural, one {# contraseña guardada fue rechazada} other {# contraseñas guardadas fueron rechazadas}}: inicia sesión en esas cuentas desde la lista de cuentas.",
"deleted": "Lista guardada eliminada.",
"prompt_title": "¿Restaurar tus otras cuentas?",
"prompt_description": "Hay una lista de cuentas guardada para {account}. Introduce la contraseña de la copia para añadirlas a este navegador, o hazlo más tarde desde Ajustes.",
"prompt_not_now": "Ahora no",
"prompt_done": "Listo",
"errors": {
"mismatch": "Las contraseñas no coinciden.",
"password_length": "Usa al menos 10 caracteres.",
+4
View File
@@ -1762,6 +1762,10 @@
"signed_in": "Salvestatud parooliga logiti sisse {count, plural, one {# kontosse} other {# kontosse}}.",
"sign_in_failed": "{count, plural, one {# salvestatud parool lükati tagasi} other {# salvestatud parooli lükati tagasi}}: logi neisse kontodesse kontode nimekirjast sisse.",
"deleted": "Salvestatud nimekiri kustutatud.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "Paroolid ei kattu.",
"password_length": "Kasuta vähemalt 10 märki.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Connecté à {count, plural, one {# compte} other {# comptes}} avec le mot de passe enregistré.",
"sign_in_failed": "{count, plural, one {# mot de passe enregistré a été refusé} other {# mots de passe enregistrés ont été refusés}} : connectez-vous à ces comptes depuis la liste des comptes.",
"deleted": "Liste enregistrée supprimée.",
"prompt_title": "Restaurer vos autres comptes ?",
"prompt_description": "Une liste de comptes est enregistrée pour {account}. Saisissez le mot de passe de la sauvegarde pour les ajouter à ce navigateur, ou faites-le plus tard dans les Paramètres.",
"prompt_not_now": "Plus tard",
"prompt_done": "Terminé",
"errors": {
"mismatch": "Les mots de passe ne correspondent pas.",
"password_length": "Utilisez au moins 10 caractères.",
+4
View File
@@ -1739,6 +1739,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Accesso fatto a {count, plural, one {# account} other {# account}} con la password salvata.",
"sign_in_failed": "{count, plural, one {# password salvata è stata rifiutata} other {# password salvate sono state rifiutate}}: accedi a quegli account dall'elenco account.",
"deleted": "Elenco salvato eliminato.",
"prompt_title": "Ripristinare gli altri account?",
"prompt_description": "Per {account} è salvato un elenco di account. Inserisci la password del backup per aggiungerli a questo browser, oppure fallo più tardi dalle Impostazioni.",
"prompt_not_now": "Non ora",
"prompt_done": "Fatto",
"errors": {
"mismatch": "Le password non coincidono.",
"password_length": "Usa almeno 10 caratteri.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1777,6 +1777,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",
+4
View File
@@ -1774,6 +1774,10 @@
"signed_in": "Signed in to {count, plural, one {# account} other {# accounts}} with the saved password.",
"sign_in_failed": "{count, plural, one {# saved password was} other {# saved passwords were}} refused: sign in to those accounts from the account list.",
"deleted": "Saved list deleted.",
"prompt_title": "Restore your other accounts?",
"prompt_description": "A list of accounts is saved for {account}. Enter its backup password to add them to this browser, or do it later from Settings.",
"prompt_not_now": "Not now",
"prompt_done": "Done",
"errors": {
"mismatch": "The passwords do not match.",
"password_length": "Use at least 10 characters.",