mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-30 16:16:19 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
Reference in New Issue
Block a user