From 9560a8b74fd1e8f05a419434330e6d9f94352c2f Mon Sep 17 00:00:00 2001 From: lucamzanon Date: Fri, 9 Oct 2026 10:54:38 +0200 Subject: [PATCH] feat: optional encrypted backup of the account list Users with several accounts can save the list of accounts in their browser (address, server, label, colour) on the webmail server and bring it back on another device. The list is encrypted in the browser with a password the user chooses (PBKDF2-SHA-256, 600,000 iterations, AES-256-GCM, owner bound as additional data); the server stores only the envelope, one file per owner under SETTINGS_DATA_DIR/account-archives. No mailbox passwords are included. Off by default behind the admin feature gate accountArchiveEnabled, pinned off in Lite. Every method of /api/account-archive needs a session cookie of the owner, matched without regard to case, whose credential is checked against the mail server on each call; listing included. Restored accounts this browser lacks are listed as awaiting sign-in: they survive a reload, and opening one leads to the sign-in form with the address filled in. Accounts already present get their archived label and colour. --- app/(main)/[locale]/login/page.tsx | 3 +- app/(main)/admin/_tabs/policy.tsx | 1 + app/api/account-archive/route.ts | 87 +++++++++++++ .../account-archive-settings.test.tsx | 77 +++++++++++ .../settings/account-archive-settings.tsx | 123 ++++++++++++++++++ components/settings/account-settings.tsx | 6 + lib/__tests__/account-archive-route.test.ts | 100 ++++++++++++++ lib/__tests__/account-archive.test.ts | 57 ++++++++ lib/__tests__/lite-config.test.ts | 3 +- lib/account-archive-client.ts | 63 +++++++++ lib/account-archive-storage.ts | 41 ++++++ lib/account-archive.ts | 97 ++++++++++++++ lib/admin/types.ts | 3 + lib/lite-config.ts | 2 + locales/ar/common.json | 27 ++++ locales/ca/common.json | 27 ++++ locales/cs/common.json | 27 ++++ locales/da/common.json | 27 ++++ locales/de/common.json | 27 ++++ locales/en/common.json | 27 ++++ locales/es/common.json | 27 ++++ locales/fa/common.json | 27 ++++ locales/fr/common.json | 27 ++++ locales/he/common.json | 27 ++++ locales/hu/common.json | 27 ++++ locales/it/common.json | 27 ++++ locales/ja/common.json | 27 ++++ locales/ko/common.json | 27 ++++ locales/lv/common.json | 27 ++++ locales/mn/common.json | 27 ++++ locales/nb/common.json | 27 ++++ locales/nl/common.json | 27 ++++ locales/pl/common.json | 27 ++++ locales/pt/common.json | 27 ++++ locales/ro/common.json | 27 ++++ locales/ru/common.json | 27 ++++ locales/sk/common.json | 27 ++++ locales/tr/common.json | 27 ++++ locales/uk/common.json | 27 ++++ locales/zh-TW/common.json | 27 ++++ locales/zh/common.json | 27 ++++ scripts/lite/lib.mjs | 2 + .../auth-store-archive-restore.test.ts | 97 ++++++++++++++ stores/account-store.ts | 7 + stores/auth-store.ts | 8 ++ 45 files changed, 1504 insertions(+), 2 deletions(-) create mode 100644 app/api/account-archive/route.ts create mode 100644 components/settings/__tests__/account-archive-settings.test.tsx create mode 100644 components/settings/account-archive-settings.tsx create mode 100644 lib/__tests__/account-archive-route.test.ts create mode 100644 lib/__tests__/account-archive.test.ts create mode 100644 lib/account-archive-client.ts create mode 100644 lib/account-archive-storage.ts create mode 100644 lib/account-archive.ts create mode 100644 stores/__tests__/auth-store-archive-restore.test.ts diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 62ec0967a..413ab869a 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -168,7 +168,8 @@ function LoginPageContent() { : undefined; const [formData, setFormData] = useState({ - username: "", + // An account restored from the list backup opens this form with its address. + username: isAddAccountMode ? searchParams.get("username") ?? "" : "", password: "", }); const [jmapEndpoint, setJmapEndpoint] = useState(""); diff --git a/app/(main)/admin/_tabs/policy.tsx b/app/(main)/admin/_tabs/policy.tsx index bab81c8ad..8d59efcb4 100644 --- a/app/(main)/admin/_tabs/policy.tsx +++ b/app/(main)/admin/_tabs/policy.tsx @@ -38,6 +38,7 @@ const FEATURE_GATE_LABELS: Partial NextResponse.json(data, { + status, headers: { 'Cache-Control': 'no-store' }, +}); + +/** + * The owner whose archive this request may touch, or the response refusing it. + * + * A session cookie alone is not enough: cookies for admin-configured servers + * can outlive the credential inside them. So the credential in the matching + * slot is checked against the mail server on every call, and listing is held + * to the same rule as writing, since the ciphertext is what an offline + * password guess would need. + */ +async function verifiedOwner(request: NextRequest): Promise { + await configManager.ensureLoaded(); + if (!configManager.getPolicy().features.accountArchiveEnabled || !hasSessionSecret()) return reply({ error: 'disabled' }, 404); + const denied = reply({ error: 'owner_signin_required' }, 403); + let claimed: string; + try { + claimed = archiveIdentity({ + username: request.headers.get('x-archive-username') ?? '', + serverUrl: request.headers.get('x-archive-server') ?? '', + }); + } catch { return denied; } + const store = await cookies(); + for (let slot = 0; slot < MAX_ACCOUNT_SLOTS; slot++) { + const ctx = readStalwartAuthContextFromStore(store, slot); + if (!ctx) continue; + try { if (archiveIdentity(ctx) !== claimed) continue; } catch { continue; } + try { + // The cookie's own username, not the header's: the two may differ in case. + assertBasicAuthMatchesUsername(ctx.authHeader, ctx.username); + const trusted = resolveTrustedJmapUrl(ctx.serverUrl, + configManager.get('jmapServerUrl', ''), + parseJmapServers(configManager.get('jmapServers', []))); + await verifyJmapAuth(ctx.serverUrl, ctx.authHeader, { trusted: !!trusted }); + } catch { return denied; } + return { username: ctx.accountName ?? ctx.username, serverUrl: ctx.serverUrl }; + } + return denied; +} + +export async function GET(request: NextRequest) { + const owner = await verifiedOwner(request); + if (owner instanceof NextResponse) return owner; + try { return reply({ envelope: await loadArchive(owner) }); } + catch { return reply({ error: 'storage_failed' }, 500); } +} + +export async function PUT(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + const owner = await verifiedOwner(request); + if (owner instanceof NextResponse) return owner; + if (Number(request.headers.get('content-length') ?? 0) > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413); + let envelope; + try { + const text = await request.text(); + if (text.length > ARCHIVE_MAX_BYTES) return reply({ error: 'too_large' }, 413); + envelope = parseArchiveEnvelope((JSON.parse(text) as { envelope?: unknown })?.envelope); + } catch { return reply({ error: 'invalid_archive' }, 400); } + try { await saveArchive(owner, envelope); return reply({ ok: true }); } + catch { return reply({ error: 'storage_failed' }, 500); } +} + +export async function DELETE(request: NextRequest) { + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + const owner = await verifiedOwner(request); + if (owner instanceof NextResponse) return owner; + try { await deleteArchive(owner); return reply({ ok: true }); } + catch { return reply({ error: 'storage_failed' }, 500); } +} diff --git a/components/settings/__tests__/account-archive-settings.test.tsx b/components/settings/__tests__/account-archive-settings.test.tsx new file mode 100644 index 000000000..eabb4737a --- /dev/null +++ b/components/settings/__tests__/account-archive-settings.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { AccountArchiveSettings } from '../account-archive-settings'; +import { useAccountStore } from '@/stores/account-store'; +import { useAuthStore } from '@/stores/auth-store'; + +const mocks = vi.hoisted(() => ({ apiFetch: vi.fn(), stored: null as unknown })); + +vi.mock('next-intl', () => { + const t = (key: string, values?: Record) => values ? `${key} ${JSON.stringify(values)}` : key; + return { useTranslations: () => t, useLocale: () => 'en' }; +}); +vi.mock('@/lib/browser-navigation', async (importOriginal) => ({ + ...(await importOriginal()), + 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, +}; + +beforeEach(() => { + mocks.stored = null; + mocks.apiFetch.mockReset().mockImplementation(async (_url: string, init: RequestInit = {}) => { + if (init.method === 'PUT') mocks.stored = JSON.parse(String(init.body)).envelope; + if (init.method === 'DELETE') mocks.stored = null; + return new Response(JSON.stringify(init.method ? { ok: true } : { envelope: mocks.stored }), { status: 200 }); + }); + useAuthStore.setState(owner); + useAccountStore.setState({ accounts: [account] as never, activeAccountId: account.id, defaultAccountId: account.id }); +}); + +const fill = (label: string, value: string) => fireEvent.change(screen.getByLabelText(label), { target: { value } }); + +describe('AccountArchiveSettings', () => { + it('saves the list encrypted, then restores it on a browser that lacks the accounts', async () => { + render(); + await screen.findByText(/^status_none/); + fireEvent.click(screen.getByRole('button', { name: 'save' })); + fill('password', 'a long backup password'); + fill('confirm', 'a different password!'); + fireEvent.submit(screen.getByLabelText('confirm').closest('form')!); + expect(await screen.findByRole('alert')).toHaveTextContent('errors.mismatch'); + expect(mocks.apiFetch).not.toHaveBeenCalledWith('/api/account-archive', expect.objectContaining({ method: 'PUT' })); + + fill('confirm', 'a long backup password'); + fireEvent.submit(screen.getByLabelText('confirm').closest('form')!); + expect(await screen.findByRole('status', {}, { timeout: 5000 })).toHaveTextContent('saved {"count":1}'); + const [, init] = mocks.apiFetch.mock.calls.find(([, i]) => i?.method === 'PUT')!; + expect(init.headers).toMatchObject({ 'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl }); + expect(String(init.body)).not.toContain('x.test'); + expect(String(init.body)).not.toContain('Work'); + + // Another browser: same owner signed in, the other account not there yet. + useAccountStore.setState({ accounts: [{ ...account, label: 'Renamed', avatarColor: '#000000' }] as never }); + fireEvent.click(screen.getByRole('button', { name: 'restore' })); + fill('password', 'a long backup password'); + fireEvent.submit(screen.getByLabelText('password').closest('form')!); + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent('restored'), { timeout: 5000 }); + expect(useAccountStore.getState().accounts[0]).toMatchObject({ label: 'Work', avatarColor: '#2563eb' }); + }); + + it('reports a wrong restore password', async () => { + render(); + fireEvent.click(await screen.findByRole('button', { name: 'save' })); + fill('password', 'a long backup password'); + fill('confirm', 'a long backup password'); + fireEvent.submit(screen.getByLabelText('confirm').closest('form')!); + await screen.findByRole('status', {}, { timeout: 5000 }); + fireEvent.click(screen.getByRole('button', { name: 'restore' })); + fill('password', 'not the password'); + fireEvent.submit(screen.getByLabelText('password').closest('form')!); + expect(await screen.findByRole('alert', {}, { timeout: 5000 })).toHaveTextContent('errors.unlock_failed'); + }); +}); diff --git a/components/settings/account-archive-settings.tsx b/components/settings/account-archive-settings.tsx new file mode 100644 index 000000000..9a4215502 --- /dev/null +++ b/components/settings/account-archive-settings.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { useEffect, useId, useState } from 'react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { useAuthStore } from '@/stores/auth-store'; +import { decryptArchive, encryptArchive, ARCHIVE_MIN_PASSWORD, type ArchiveOwner } from '@/lib/account-archive'; +import { collectAccounts, fetchArchive, putArchive, removeArchive, restoreAccounts } from '@/lib/account-archive-client'; +import { toUnicodeEmail } from '@/lib/idn'; +import { SettingsSection } from './settings-section'; + +const KNOWN_ERRORS = ['mismatch', 'password_length', 'unlock_failed', 'owner_signin_required', 'https_required']; + +/** 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'); + const id = useId(); + const username = useAuthStore(s => s.username); + const serverUrl = useAuthStore(s => s.serverUrl); + const [saved, setSaved] = useState(null); + const [mode, setMode] = useState<'save' | 'restore' | null>(null); + const [password, setPassword] = useState(''); + const [confirmation, setConfirmation] = useState(''); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState<{ error: boolean; text: string } | null>(null); + const owner: ArchiveOwner | null = username && serverUrl ? { username, serverUrl } : null; + + useEffect(() => { + setSaved(null); + if (!username || !serverUrl) return; + let cancelled = false; + fetchArchive({ username, serverUrl }) + .then(envelope => { if (!cancelled) setSaved(envelope !== null); }) + .catch(() => { if (!cancelled) setSaved(false); }); + return () => { cancelled = true; }; + }, [username, serverUrl]); + + if (!owner) return null; + + const open = (next: 'save' | 'restore' | null) => { + setMode(next); setPassword(''); setConfirmation(''); setMessage(null); + }; + const run = async (action: () => Promise) => { + setBusy(true); setMessage(null); + try { + const text = await action(); + 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); } + }; + + const save = () => run(async () => { + if (password !== confirmation) throw new Error('mismatch'); + const accounts = collectAccounts(); + await putArchive(owner, await encryptArchive(owner, accounts, password)); + setSaved(true); + return t('saved', { count: accounts.length }); + }); + const restore = () => run(async () => { + const envelope = await fetchArchive(owner); + if (!envelope) { setSaved(false); throw new Error('storage_failed'); } + const result = restoreAccounts(await decryptArchive(owner, envelope, password)); + return [t('restored', result), result.skipped ? t('skipped', { count: result.skipped }) : ''].join(' ').trim(); + }); + const remove = () => { + if (!window.confirm(t('delete_confirm'))) return; + void run(async () => { await removeArchive(owner); setSaved(false); return t('deleted'); }); + }; + + return ( + +

+ {saved === null ? '…' : t(saved ? 'status_saved' : 'status_none', { account: toUnicodeEmail(owner.username) })} +

+ {mode === null ? ( +
+ + {saved && } + {saved && } +
+ ) : ( +
{ e.preventDefault(); void (mode === 'save' ? save() : restore()); }} + > + + setPassword(e.target.value)} + /> +

+ {t(mode === 'save' ? 'password_hint' : 'restore_hint')} +

+ {mode === 'save' && ( + <> + + setConfirmation(e.target.value)} + /> + + )} +
+ + +
+
+ )} + {message && ( +

+ {message.text} +

+ )} +
+ ); +} diff --git a/components/settings/account-settings.tsx b/components/settings/account-settings.tsx index a0ddbcb95..92e17490a 100644 --- a/components/settings/account-settings.tsx +++ b/components/settings/account-settings.tsx @@ -15,6 +15,9 @@ import { useRouter } from '@/i18n/navigation'; import { getMaxAccounts } from '@/lib/account-utils'; import { formatFileSize, cn } from '@/lib/utils'; import { toUnicodeDomain, toUnicodeEmail } from '@/lib/idn'; +import { IS_LITE } from '@/lib/lite'; +import { usePolicyStore } from '@/stores/policy-store'; +import { AccountArchiveSettings } from './account-archive-settings'; function hostnameOf(serverUrl: string): string { try { return toUnicodeDomain(new URL(serverUrl).hostname); } catch { return serverUrl; } @@ -38,6 +41,7 @@ export function AccountSettings() { const activeAccountId = useAuthStore((s) => s.activeAccountId); const switchAccount = useAuthStore((s) => s.switchAccount); const setManagedAccount = useManagedAccountStore((s) => s.setManagedAccount); + const archiveEnabled = usePolicyStore((s) => s.isFeatureEnabled('accountArchiveEnabled')) && !IS_LITE && !isDemoMode; // Shared/group accounts delegated to this session (excludes the user's own // primary account). These can be drilled into for scoped settings editing. @@ -235,6 +239,8 @@ export function AccountSettings() { )} + {archiveEnabled && } + {/* Shared / group accounts delegated to this session. Clicking one drills into a scoped settings view (filters, vacation, calendars, contacts). */} {sharedAccounts.length > 0 && ( diff --git a/lib/__tests__/account-archive-route.test.ts b/lib/__tests__/account-archive-route.test.ts new file mode 100644 index 000000000..e52ed703e --- /dev/null +++ b/lib/__tests__/account-archive-route.test.ts @@ -0,0 +1,100 @@ +// @vitest-environment node +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest'; +import { NextRequest } from 'next/server'; + +const mocks = vi.hoisted(() => ({ + enabled: true, contexts: new Map(), verify: vi.fn(), +})); +vi.mock('@/lib/admin/config-manager', () => ({ configManager: { + ensureLoaded: async () => {}, + get: (_key: string, fallback: unknown) => fallback, + getPolicy: () => ({ features: { accountArchiveEnabled: mocks.enabled } }), +} })); +vi.mock('@/lib/auth/session-secret', () => ({ hasSessionSecret: () => true })); +vi.mock('next/headers', () => ({ cookies: async () => ({}) })); +vi.mock('@/lib/stalwart/auth-context', () => ({ + readStalwartAuthContextFromStore: (_: unknown, slot: number) => mocks.contexts.get(slot) ?? null, +})); +vi.mock('@/lib/auth/verify-jmap-auth', async importOriginal => ({ + ...await importOriginal(), verifyJmapAuth: mocks.verify, +})); + +const dataDir = mkdtempSync(path.join(tmpdir(), 'bw-account-archive-')); +process.env.SETTINGS_DATA_DIR = dataDir; +afterAll(() => rmSync(dataDir, { recursive: true, force: true })); + +import { DELETE, GET, PUT } from '@/app/api/account-archive/route'; + +const owner = { username: 'owner@example.com', serverUrl: 'https://mail.example.com' }; +const envelope = { version: 1, iterations: 600000, salt: 'A'.repeat(22) + '==', iv: 'A'.repeat(16), ciphertext: 'A'.repeat(43) + '=' }; +const basic = (user: string) => 'Basic ' + Buffer.from(`${user}:secret`).toString('base64'); + +function request(method: string, body?: unknown, headers: Record = {}) { + return new NextRequest('https://webmail.example.com/api/account-archive', { + method, + headers: { 'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl, 'content-type': 'application/json', ...headers }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} +const signIn = (username = owner.username, slot = 3) => { + mocks.contexts.set(slot, { username, serverUrl: owner.serverUrl, authHeader: basic(username) }); +}; + +beforeEach(() => { + mocks.enabled = true; + mocks.contexts.clear(); + mocks.verify.mockReset().mockResolvedValue(owner.serverUrl); +}); + +describe('account archive API', () => { + it('is off unless the admin enables it', async () => { + mocks.enabled = false; + signIn(); + expect((await GET(request('GET'))).status).toBe(404); + expect((await PUT(request('PUT', { envelope }))).status).toBe(404); + expect((await DELETE(request('DELETE'))).status).toBe(404); + }); + + it('refuses every method without a verified session of the owner', async () => { + for (const call of [() => GET(request('GET')), () => PUT(request('PUT', { envelope })), () => DELETE(request('DELETE'))]) { + expect((await call()).status).toBe(403); + } + // Another user's session, or a credential that does not match its cookie. + mocks.contexts.set(0, { ...owner, username: 'someone@example.com', authHeader: basic('someone@example.com') }); + mocks.contexts.set(1, { ...owner, authHeader: basic('someone@example.com') }); + expect((await GET(request('GET'))).status).toBe(403); + // The owner's cookie whose credential the mail server no longer accepts. + mocks.contexts.clear(); + signIn(); + mocks.verify.mockRejectedValue(new Error('401')); + expect((await GET(request('GET'))).status).toBe(403); + expect((await PUT(request('PUT', { envelope }))).status).toBe(403); + }); + + it('stores, returns and deletes the envelope for the verified owner, in any case', async () => { + signIn('Owner@Example.com'); + let res = await GET(request('GET')); + expect(res.status).toBe(200); + expect(res.headers.get('Cache-Control')).toBe('no-store'); + expect((await res.json()).envelope).toBeNull(); + + res = await PUT(request('PUT', { envelope: { ...envelope, password: 'forbidden' }, accounts: ['forbidden'] })); + expect(res.status).toBe(200); + expect(mocks.verify).toHaveBeenCalledWith(owner.serverUrl, basic('Owner@Example.com'), { trusted: false }); + expect((await (await GET(request('GET'))).json()).envelope).toEqual(envelope); + + expect((await DELETE(request('DELETE'))).status).toBe(200); + expect((await (await GET(request('GET'))).json()).envelope).toBeNull(); + }); + + it('rejects cross-site writes, malformed envelopes and oversized bodies', async () => { + signIn(); + expect((await PUT(request('PUT', { envelope }, { 'sec-fetch-site': 'cross-site' }))).status).toBe(403); + expect((await DELETE(request('DELETE', undefined, { 'sec-fetch-site': 'cross-site' }))).status).toBe(403); + expect((await PUT(request('PUT', { envelope: { ...envelope, iterations: 1 } }))).status).toBe(400); + expect((await PUT(request('PUT', { envelope: { ...envelope, ciphertext: 'A'.repeat(70000) } }))).status).toBe(413); + }); +}); diff --git a/lib/__tests__/account-archive.test.ts b/lib/__tests__/account-archive.test.ts new file mode 100644 index 000000000..2c5bc6048 --- /dev/null +++ b/lib/__tests__/account-archive.test.ts @@ -0,0 +1,57 @@ +// @vitest-environment node +import { describe, expect, it } from 'vitest'; +import { + archiveIdentity, decryptArchive, encryptArchive, parseArchiveEnvelope, parseArchivedAccounts, type ArchivedAccount, +} from '@/lib/account-archive'; + +const owner = { username: 'owner@example.com', serverUrl: 'https://mail.example.com' }; +const accounts: ArchivedAccount[] = [ + { username: 'owner@example.com', serverUrl: 'https://mail.example.com', label: 'Work', avatarColor: '#2563eb' }, + { username: 'me@other.example', serverUrl: 'https://jmap.other.example/', label: 'Home', avatarColor: '#16a34a' }, +]; +const password = 'correct horse battery'; + +describe('account archive encryption', () => { + it('round-trips the account list and leaves only ciphertext in the envelope', async () => { + const envelope = await encryptArchive(owner, accounts, password); + expect(JSON.stringify(envelope)).not.toContain('example'); + expect(JSON.stringify(envelope)).not.toContain('Work'); + expect(await decryptArchive(owner, envelope, password)).toEqual([ + accounts[0], { ...accounts[1], serverUrl: 'https://jmap.other.example' }, + ]); + }); + + it('refuses a wrong password and an envelope moved to another owner', async () => { + const envelope = await encryptArchive(owner, accounts, password); + await expect(decryptArchive(owner, envelope, 'wrong password!')).rejects.toThrow('unlock_failed'); + await expect(decryptArchive({ ...owner, username: 'thief@example.com' }, envelope, password)).rejects.toThrow('unlock_failed'); + }); + + it('treats the owner username without regard to case', async () => { + const envelope = await encryptArchive({ ...owner, username: 'Owner@Example.com' }, accounts, password); + expect(await decryptArchive(owner, envelope, password)).toHaveLength(2); + expect(archiveIdentity({ ...owner, username: 'OWNER@example.com' })).toBe(archiveIdentity(owner)); + expect(archiveIdentity({ ...owner, serverUrl: 'https://mail.example.com/' })).toBe(archiveIdentity(owner)); + }); + + it('demands a password of at least 10 characters', async () => { + await expect(encryptArchive(owner, accounts, 'short')).rejects.toThrow('password_length'); + }); + + it('never carries a password or any other extra field', async () => { + const withSecrets = accounts.map(a => ({ ...a, password: 'mailbox-secret', token: 't' })); + const envelope = await encryptArchive(owner, withSecrets, password); + const restored = await decryptArchive(owner, envelope, password); + expect(JSON.stringify(restored)).not.toContain('mailbox-secret'); + expect(Object.keys(restored[0]).sort()).toEqual(['avatarColor', 'label', 'serverUrl', 'username']); + }); + + it('rejects malformed lists and envelopes', async () => { + expect(() => parseArchivedAccounts([])).toThrow('invalid_archive'); + expect(() => parseArchivedAccounts([{ ...accounts[0], avatarColor: 'red' }])).toThrow('invalid_archive'); + const envelope = await encryptArchive(owner, accounts, password); + expect(parseArchiveEnvelope({ ...envelope, extra: 'x' })).toEqual(envelope); + expect(() => parseArchiveEnvelope({ ...envelope, iterations: 1000 })).toThrow('invalid_archive'); + expect(() => parseArchiveEnvelope({ ...envelope, iv: 'AAAA' })).toThrow('invalid_archive'); + }); +}); diff --git a/lib/__tests__/lite-config.test.ts b/lib/__tests__/lite-config.test.ts index e347d1b80..508be6a11 100644 --- a/lib/__tests__/lite-config.test.ts +++ b/lib/__tests__/lite-config.test.ts @@ -103,13 +103,14 @@ describe('applyLitePolicy (policy.json for the static build)', () => { it('honours deployer gates and restrictions but never re-enables plugins', () => { const policy = applyLitePolicy({ - features: { pluginsEnabled: true, sidebarAppsEnabled: true, calendarEnabled: false }, + features: { pluginsEnabled: true, sidebarAppsEnabled: true, calendarEnabled: false, accountArchiveEnabled: true }, restrictions: { theme: { locked: true, value: 'dark' } }, forceEnabledPlugins: ['evil'], defaultSidebarApps: [{ id: 'x' }], }); expect(policy.features.pluginsEnabled).toBe(false); expect(policy.features.sidebarAppsEnabled).toBe(false); + expect(policy.features.accountArchiveEnabled).toBe(false); expect(policy.features.calendarEnabled).toBe(false); expect(policy.restrictions.theme).toEqual({ locked: true, value: 'dark' }); expect(policy.forceEnabledPlugins).toEqual([]); diff --git a/lib/account-archive-client.ts b/lib/account-archive-client.ts new file mode 100644 index 000000000..2ca00f455 --- /dev/null +++ b/lib/account-archive-client.ts @@ -0,0 +1,63 @@ +import { apiFetch } from '@/lib/browser-navigation'; +import { IS_LITE } from '@/lib/lite'; +import { generateAccountId, getMaxAccounts } from '@/lib/account-utils'; +import { useAccountStore } from '@/stores/account-store'; +import type { ArchiveEnvelope, ArchiveOwner, ArchivedAccount } from './account-archive'; + +async function request(owner: ArchiveOwner, init: RequestInit = {}): Promise<{ envelope?: ArchiveEnvelope | null }> { + // The archive lives on this app's server; the static Lite build has none. + if (IS_LITE) throw new Error('disabled'); + const res = await apiFetch('/api/account-archive', { + cache: 'no-store', ...init, + headers: { + 'x-archive-username': owner.username, 'x-archive-server': owner.serverUrl, + ...(init.body ? { 'Content-Type': 'application/json' } : {}), + }, + }); + const body = await res.json().catch(() => ({})); + if (!res.ok) throw new Error(typeof body.error === 'string' ? body.error : 'storage_failed'); + return body; +} + +export async function fetchArchive(owner: ArchiveOwner): Promise { + return (await request(owner)).envelope ?? null; +} +export async function putArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope): Promise { + await request(owner, { method: 'PUT', body: JSON.stringify({ envelope }) }); +} +export async function removeArchive(owner: ArchiveOwner): Promise { + await request(owner, { method: 'DELETE' }); +} + +/** This browser's accounts as the archive keeps them: no credentials of any kind. */ +export function collectAccounts(): ArchivedAccount[] { + return useAccountStore.getState().accounts.map(a => ({ + username: a.username, serverUrl: a.serverUrl, label: a.label, avatarColor: a.avatarColor, + })); +} + +/** + * Adds the archived accounts this browser lacks, each waiting for its own + * sign-in, and gives the ones it has their archived label and colour. + */ +export function restoreAccounts(archived: ArchivedAccount[]): { added: number; updated: number; skipped: number } { + const store = useAccountStore.getState(); + let added = 0, updated = 0, skipped = 0; + for (const entry of archived) { + const id = generateAccountId(entry.username, entry.serverUrl); + if (store.getAccountById(id)) { + store.updateAccount(id, { label: entry.label, avatarColor: entry.avatarColor }); + updated++; + continue; + } + if (useAccountStore.getState().accounts.length >= getMaxAccounts()) { skipped++; continue; } + store.addAccount({ + username: entry.username, serverUrl: entry.serverUrl, authMode: 'basic', rememberMe: false, + label: entry.label, displayName: entry.label, email: entry.username, lastLoginAt: 0, + isConnected: false, hasError: true, isDefault: false, awaitingSignIn: true, + }); + store.updateAccount(id, { avatarColor: entry.avatarColor }); + added++; + } + return { added, updated, skipped }; +} diff --git a/lib/account-archive-storage.ts b/lib/account-archive-storage.ts new file mode 100644 index 000000000..c27c6d58a --- /dev/null +++ b/lib/account-archive-storage.ts @@ -0,0 +1,41 @@ +import { createHash, randomBytes } from 'node:crypto'; +import { mkdir, open, readFile, rename, unlink } from 'node:fs/promises'; +import path from 'node:path'; +import { archiveIdentity, parseArchiveEnvelope, type ArchiveEnvelope, type ArchiveOwner } from './account-archive'; + +/** One file per owner: `SETTINGS_DATA_DIR/account-archives/.json`. */ +function archiveDir(): string { + return path.join(process.env.SETTINGS_DATA_DIR || path.join(process.cwd(), 'data', 'settings'), 'account-archives'); +} +function archivePath(owner: ArchiveOwner): string { + return path.join(archiveDir(), createHash('sha256').update(archiveIdentity(owner)).digest('hex') + '.json'); +} + +export async function loadArchive(owner: ArchiveOwner): Promise { + try { return parseArchiveEnvelope(JSON.parse(await readFile(archivePath(owner), 'utf8'))); } + catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return null; + throw err; + } +} + +/** Replaces the owner's archive atomically: a reader sees the old file or the new one, never half of it. */ +export async function saveArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope): Promise { + const target = archivePath(owner); + const temporary = `${target}.${randomBytes(8).toString('hex')}.tmp`; + await mkdir(archiveDir(), { recursive: true, mode: 0o700 }); + try { + const file = await open(temporary, 'wx', 0o600); + try { await file.writeFile(JSON.stringify(parseArchiveEnvelope(envelope))); await file.sync(); } + finally { await file.close(); } + await rename(temporary, target); + } finally { + await unlink(temporary).catch(() => {}); + } +} + +export async function deleteArchive(owner: ArchiveOwner): Promise { + await unlink(archivePath(owner)).catch(err => { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err; + }); +} diff --git a/lib/account-archive.ts b/lib/account-archive.ts new file mode 100644 index 000000000..fa64a505f --- /dev/null +++ b/lib/account-archive.ts @@ -0,0 +1,97 @@ +/** + * Encrypted list of a user's accounts, kept on the server so another browser + * can bring the same accounts back. Encryption happens in the browser with a + * password the user chooses; the server only ever sees the envelope. + * + * The list holds no mailbox passwords: each restored account is signed in to + * by hand. + */ + +export interface ArchiveOwner { username: string; serverUrl: string } +export interface ArchivedAccount extends ArchiveOwner { label: string; avatarColor: string } +export interface ArchiveEnvelope { + version: 1; + iterations: typeof ARCHIVE_ITERATIONS; + salt: string; + iv: string; + ciphertext: string; +} + +export const ARCHIVE_ITERATIONS = 600000; +export const ARCHIVE_MAX_BYTES = 64 * 1024; +export const ARCHIVE_MAX_ACCOUNTS = 50; +export const ARCHIVE_MIN_PASSWORD = 10; + +const trimUrl = (url: string) => url.replace(/\/+$/, ''); + +/** + * One owner however the username is cased: `Lu@example.com` and + * `lu@example.com` sign in to the same mailbox, so they share the archive. + */ +export function archiveIdentity(owner: ArchiveOwner): string { + if (typeof owner?.username !== 'string' || !owner.username.trim() || owner.username.length > 320 + || typeof owner.serverUrl !== 'string' || !owner.serverUrl || owner.serverUrl.length > 2048) { + throw new Error('invalid_archive'); + } + return JSON.stringify([owner.username.trim().toLowerCase(), trimUrl(owner.serverUrl)]); +} + +/** Strict allowlist: nothing but the envelope fields is stored or echoed. */ +export function parseArchiveEnvelope(value: unknown): ArchiveEnvelope { + const v = value as ArchiveEnvelope | null; + const base64 = (s: unknown, bytes: (n: number) => boolean): s is string => { + if (typeof s !== 'string' || s.length > ARCHIVE_MAX_BYTES || !/^[A-Za-z0-9+/]+={0,2}$/.test(s) || s.length % 4) return false; + try { return bytes(atob(s).length); } catch { return false; } + }; + if (!v || typeof v !== 'object' || v.version !== 1 || v.iterations !== ARCHIVE_ITERATIONS + || !base64(v.salt, n => n === 16) || !base64(v.iv, n => n === 12) || !base64(v.ciphertext, n => n > 16)) { + throw new Error('invalid_archive'); + } + return { version: 1, iterations: ARCHIVE_ITERATIONS, salt: v.salt, iv: v.iv, ciphertext: v.ciphertext }; +} + +export function parseArchivedAccounts(value: unknown): ArchivedAccount[] { + if (!Array.isArray(value) || value.length < 1 || value.length > ARCHIVE_MAX_ACCOUNTS) throw new Error('invalid_archive'); + return value.map((a: ArchivedAccount) => { + archiveIdentity(a); + if (typeof a.label !== 'string' || a.label.length > 320 + || typeof a.avatarColor !== 'string' || !/^#[0-9a-f]{6}$/i.test(a.avatarColor)) throw new Error('invalid_archive'); + // Rebuilt field by field, so nothing else (a password, say) survives a round trip. + return { username: a.username.trim(), serverUrl: trimUrl(a.serverUrl), label: a.label, avatarColor: a.avatarColor }; + }); +} + +const encode = (bytes: Uint8Array) => btoa(String.fromCharCode(...bytes)); +const decode = (text: string) => Uint8Array.from(atob(text), c => c.charCodeAt(0)); +// The owner is sealed in: an envelope copied to another owner's slot will not open there. +const additionalData = (owner: ArchiveOwner) => new TextEncoder().encode(`bulwark-account-archive:1:${archiveIdentity(owner)}`); + +async function deriveKey(password: string, salt: Uint8Array): Promise { + if (!globalThis.crypto?.subtle) throw new Error('https_required'); + const material = await crypto.subtle.importKey('raw', new TextEncoder().encode(password), 'PBKDF2', false, ['deriveKey']); + return crypto.subtle.deriveKey({ name: 'PBKDF2', hash: 'SHA-256', salt, iterations: ARCHIVE_ITERATIONS }, + material, { name: 'AES-GCM', length: 256 }, false, ['encrypt', 'decrypt']); +} + +export async function encryptArchive(owner: ArchiveOwner, accounts: ArchivedAccount[], password: string): Promise { + if (password.length < ARCHIVE_MIN_PASSWORD || password.length > 1024) throw new Error('password_length'); + const plaintext = new TextEncoder().encode(JSON.stringify({ accounts: parseArchivedAccounts(accounts) })); + const salt = crypto.getRandomValues(new Uint8Array(16)); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const key = await deriveKey(password, salt); + const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv, additionalData: additionalData(owner) }, key, plaintext); + return parseArchiveEnvelope({ version: 1, iterations: ARCHIVE_ITERATIONS, salt: encode(salt), iv: encode(iv), + ciphertext: encode(new Uint8Array(ciphertext)) }); +} + +export async function decryptArchive(owner: ArchiveOwner, envelope: ArchiveEnvelope, password: string): Promise { + const clean = parseArchiveEnvelope(envelope); + if (password.length > 1024) throw new Error('unlock_failed'); + const key = await deriveKey(password, decode(clean.salt)); + let plaintext: ArrayBuffer; + try { + plaintext = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: decode(clean.iv), additionalData: additionalData(owner) }, + key, decode(clean.ciphertext)); + } catch { throw new Error('unlock_failed'); } + return parseArchivedAccounts((JSON.parse(new TextDecoder().decode(plaintext)) as { accounts?: unknown }).accounts); +} diff --git a/lib/admin/types.ts b/lib/admin/types.ts index 437712c37..807adbf5d 100644 --- a/lib/admin/types.ts +++ b/lib/admin/types.ts @@ -67,6 +67,8 @@ export interface FeatureGates { crossStarredViewEnabled: boolean; crossAllViewEnabled: boolean; unifiedCrossAccountEnabled: boolean; + /** Encrypted account list kept on the server, restorable on another device. */ + accountArchiveEnabled: boolean; } export const DEFAULT_FEATURE_GATES: FeatureGates = { @@ -94,6 +96,7 @@ export const DEFAULT_FEATURE_GATES: FeatureGates = { crossStarredViewEnabled: false, crossAllViewEnabled: false, unifiedCrossAccountEnabled: false, + accountArchiveEnabled: false, }; export interface ThemePolicy { diff --git a/lib/lite-config.ts b/lib/lite-config.ts index 38d8dd4b4..d4a64501b 100644 --- a/lib/lite-config.ts +++ b/lib/lite-config.ts @@ -162,6 +162,8 @@ export function applyLitePolicy(raw: unknown): SettingsPolicy { pluginsEnabled: false, pluginsUploadEnabled: false, sidebarAppsEnabled: false, + // Kept by this app's server, which Lite does not have. + accountArchiveEnabled: false, }, forceEnabledPlugins: [], approvedPlugins: [], diff --git a/locales/ar/common.json b/locales/ar/common.json index 6de5854db..6d7bd5973 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "اسحب لإعادة الترتيب", "add": "إضافة حساب" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "مشترك معي", "description": "الحسابات الجماعية والمشتركة التي يمكنك إدارتها. اختر واحدًا لتعديل مرشحاته، والرد التلقائي أثناء الغياب، والتقويمات، وجهات الاتصال.", diff --git a/locales/ca/common.json b/locales/ca/common.json index 01ab88b36..0c718ecdc 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Arrossegueu per reordenar", "add": "Afegeix un compte" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Compartit amb mi", "description": "Comptes de grup i compartits que podeu gestionar. Seleccioneu-ne un per editar-ne els filtres, la resposta automàtica, els calendaris i els contactes.", diff --git a/locales/cs/common.json b/locales/cs/common.json index 04c10f6e6..3a4ea0f8e 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Přetažením změňte pořadí", "add": "Přidat účet" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Sdíleno se mnou", "description": "Skupinové a sdílené účty, které můžete spravovat. Vyberte jeden pro úpravu jeho filtrů, odpovědi v nepřítomnosti, kalendářů a kontaktů.", diff --git a/locales/da/common.json b/locales/da/common.json index 0dd7e6e5a..c90f977b0 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Træk for at omarrangere", "add": "Tilføj konto" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Delt med mig", "description": "Gruppe- og delte konti, du kan administrere. Vælg en for at redigere dens filtre, autosvar ved fravær, kalendere og kontakter.", diff --git a/locales/de/common.json b/locales/de/common.json index d43a9bcd4..e6f9ed51c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Zum Sortieren ziehen", "add": "Konto hinzufügen" }, + "backup": { + "title": "Sicherung der Kontoliste", + "description": "Speichert die Liste der Konten in diesem Browser auf dem Server, verschlüsselt mit einem Passwort, das nur Sie kennen, und stellt sie auf einem anderen Gerät wieder her. Postfachpasswörter werden nicht gespeichert: Sie melden sich bei jedem wiederhergestellten Konto selbst an.", + "status_none": "Für {account} ist keine Liste gespeichert.", + "status_saved": "Für {account} ist eine Liste gespeichert.", + "save": "Liste speichern", + "restore": "Liste wiederherstellen", + "delete": "Gespeicherte Liste löschen", + "cancel": "Abbrechen", + "delete_confirm": "Die gespeicherte Kontoliste vom Server löschen?", + "password": "Sicherungspasswort", + "password_hint": "Mindestens 10 Zeichen. Es kann nicht wiederhergestellt werden: Wenn Sie es vergessen, speichern Sie die Liste erneut mit einem neuen.", + "restore_hint": "Das Passwort, das beim Speichern der Liste gewählt wurde.", + "confirm": "Passwort bestätigen", + "saved": "Liste gespeichert ({count, plural, one {# Konto} other {# Konten}}).", + "restored": "Hinzugefügte Konten: {added}. Bereits vorhandene, aktualisierte Konten: {updated}. Öffnen Sie jedes neue Konto in der Kontoliste, um sich anzumelden.", + "skipped": "{count, plural, one {# Konto wurde} other {# Konten wurden}} nicht hinzugefügt: Die Höchstzahl an Konten ist erreicht.", + "deleted": "Gespeicherte Liste gelöscht.", + "errors": { + "mismatch": "Die Passwörter stimmen nicht überein.", + "password_length": "Verwenden Sie mindestens 10 Zeichen.", + "unlock_failed": "Falsches Passwort, oder die Liste wurde für ein anderes Konto gespeichert.", + "owner_signin_required": "Melden Sie sich erneut bei diesem Konto an und versuchen Sie es dann noch einmal.", + "https_required": "Dafür ist eine sichere (HTTPS-)Verbindung nötig.", + "storage_failed": "Der Server konnte die Liste nicht lesen oder speichern. Versuchen Sie es später erneut." + } + }, "shared_accounts": { "title": "Mit mir geteilt", "description": "Gruppen- und freigegebene Konten, die Sie verwalten können. Wählen Sie eines aus, um dessen Filter, Abwesenheitsnotiz, Kalender und Kontakte zu bearbeiten.", diff --git a/locales/en/common.json b/locales/en/common.json index 4721cb6cd..ca65a0f4d 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Drag to reorder", "add": "Add account" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Shared with me", "description": "Group and shared accounts you can manage. Select one to edit its filters, vacation responder, calendars, and contacts.", diff --git a/locales/es/common.json b/locales/es/common.json index 93e017212..8b0000c55 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Arrastra para reordenar", "add": "Añadir cuenta" }, + "backup": { + "title": "Copia de la lista de cuentas", + "description": "Guarda en el servidor la lista de cuentas de este navegador, cifrada con una contraseña que solo tú conoces, y recupérala en otro dispositivo. Las contraseñas de los buzones no se guardan: inicias sesión tú en cada cuenta restaurada.", + "status_none": "No hay ninguna lista guardada para {account}.", + "status_saved": "Hay una lista guardada para {account}.", + "save": "Guardar lista", + "restore": "Restaurar lista", + "delete": "Eliminar lista guardada", + "cancel": "Cancelar", + "delete_confirm": "¿Eliminar del servidor la lista de cuentas guardada?", + "password": "Contraseña de la copia", + "password_hint": "Al menos 10 caracteres. No se puede recuperar: si la olvidas, vuelve a guardar la lista con una nueva.", + "restore_hint": "La contraseña elegida al guardar la lista.", + "confirm": "Confirmar contraseña", + "saved": "Lista guardada ({count, plural, one {# cuenta} other {# cuentas}}).", + "restored": "Cuentas añadidas: {added}. Cuentas ya presentes y actualizadas: {updated}. Abre cada cuenta nueva desde la lista de cuentas para iniciar sesión.", + "skipped": "{count, plural, one {# cuenta no se ha añadido} other {# cuentas no se han añadido}}: se ha alcanzado el límite de cuentas.", + "deleted": "Lista guardada eliminada.", + "errors": { + "mismatch": "Las contraseñas no coinciden.", + "password_length": "Usa al menos 10 caracteres.", + "unlock_failed": "Contraseña incorrecta, o la lista se guardó para otra cuenta.", + "owner_signin_required": "Vuelve a iniciar sesión en esta cuenta y reinténtalo.", + "https_required": "Se necesita una conexión segura (HTTPS).", + "storage_failed": "El servidor no ha podido leer ni guardar la lista. Inténtalo más tarde." + } + }, "shared_accounts": { "title": "Compartido conmigo", "description": "Cuentas de grupo y compartidas que puedes gestionar. Selecciona una para editar sus filtros, respuesta de vacaciones, calendarios y contactos.", diff --git a/locales/fa/common.json b/locales/fa/common.json index 342711bd2..041f8deca 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "کشیدن", "add": "افزودن" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "اشتراکی با من", "description": "حساب‌های گروهی", diff --git a/locales/fr/common.json b/locales/fr/common.json index 7c8abdbd1..f9e44d769 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Faire glisser pour réorganiser", "add": "Ajouter un compte" }, + "backup": { + "title": "Sauvegarde de la liste des comptes", + "description": "Conservez sur le serveur la liste des comptes de ce navigateur, chiffrée avec un mot de passe que vous seul connaissez, et retrouvez-la sur un autre appareil. Les mots de passe des boîtes ne sont pas enregistrés : vous vous connectez vous-même à chaque compte restauré.", + "status_none": "Aucune liste enregistrée pour {account}.", + "status_saved": "Une liste est enregistrée pour {account}.", + "save": "Enregistrer la liste", + "restore": "Restaurer la liste", + "delete": "Supprimer la liste enregistrée", + "cancel": "Annuler", + "delete_confirm": "Supprimer du serveur la liste de comptes enregistrée ?", + "password": "Mot de passe de la sauvegarde", + "password_hint": "Au moins 10 caractères. Il ne peut pas être récupéré : si vous l'oubliez, enregistrez à nouveau la liste avec un nouveau mot de passe.", + "restore_hint": "Le mot de passe choisi lors de l'enregistrement de la liste.", + "confirm": "Confirmer le mot de passe", + "saved": "Liste enregistrée ({count, plural, one {# compte} other {# comptes}}).", + "restored": "Comptes ajoutés : {added}. Comptes déjà présents et mis à jour : {updated}. Ouvrez chaque nouveau compte depuis la liste des comptes pour vous connecter.", + "skipped": "{count, plural, one {# compte n'a pas été ajouté} other {# comptes n'ont pas été ajoutés}} : la limite de comptes est atteinte.", + "deleted": "Liste enregistrée supprimée.", + "errors": { + "mismatch": "Les mots de passe ne correspondent pas.", + "password_length": "Utilisez au moins 10 caractères.", + "unlock_failed": "Mot de passe incorrect, ou la liste a été enregistrée pour un autre compte.", + "owner_signin_required": "Reconnectez-vous à ce compte, puis réessayez.", + "https_required": "Une connexion sécurisée (HTTPS) est nécessaire.", + "storage_failed": "Le serveur n'a pas pu lire ou enregistrer la liste. Réessayez plus tard." + } + }, "shared_accounts": { "title": "Partagé avec moi", "description": "Comptes de groupe et partagés que vous pouvez gérer. Sélectionnez-en un pour modifier ses filtres, son répondeur d'absence, ses calendriers et ses contacts.", diff --git a/locales/he/common.json b/locales/he/common.json index af83e9046..feac99c98 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -1628,6 +1628,33 @@ "drag_handle": "גרור לסדר", "add": "הוסף חשבון" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "משותף איתי", "description": "חשבונות קבוצתיים ומשותפים שאתה יכול לנהל. בחר אחד כדי לערוך את המסננים, המגיב לחופשה, לוחות השנה והאנשים שלו.", diff --git a/locales/hu/common.json b/locales/hu/common.json index 1850382fc..937bee3e1 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Húzd át a sorrend módosításához", "add": "Fiók hozzáadása" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Velem megosztva", "description": "Csoportos és megosztott fiókok, amelyeket kezelhet. Válasszon egyet a szűrői, távolléti válasza, naptárai és névjegyei szerkesztéséhez.", diff --git a/locales/it/common.json b/locales/it/common.json index c40767c5a..811da483c 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Trascina per riordinare", "add": "Aggiungi account" }, + "backup": { + "title": "Backup dell'elenco account", + "description": "Conserva sul server l'elenco degli account di questo browser, cifrato con una password che conosci solo tu, e ripristinalo su un altro dispositivo. Le password delle caselle non vengono salvate: accedi tu a ogni account ripristinato.", + "status_none": "Nessun elenco salvato per {account}.", + "status_saved": "C'è un elenco salvato per {account}.", + "save": "Salva elenco", + "restore": "Ripristina elenco", + "delete": "Elimina elenco salvato", + "cancel": "Annulla", + "delete_confirm": "Eliminare dal server l'elenco account salvato?", + "password": "Password del backup", + "password_hint": "Almeno 10 caratteri. Non si può recuperare: se la dimentichi, salva di nuovo l'elenco con una nuova password.", + "restore_hint": "La password scelta quando l'elenco è stato salvato.", + "confirm": "Conferma password", + "saved": "Elenco salvato ({count, plural, one {# account} other {# account}}).", + "restored": "Account aggiunti: {added}. Account già presenti e aggiornati: {updated}. Apri ogni nuovo account dall'elenco account per accedere.", + "skipped": "{count, plural, one {# account non è stato aggiunto} other {# account non sono stati aggiunti}}: è stato raggiunto il limite di account.", + "deleted": "Elenco salvato eliminato.", + "errors": { + "mismatch": "Le password non coincidono.", + "password_length": "Usa almeno 10 caratteri.", + "unlock_failed": "Password errata, oppure l'elenco è stato salvato per un altro account.", + "owner_signin_required": "Accedi di nuovo a questo account, poi riprova.", + "https_required": "Serve una connessione sicura (HTTPS).", + "storage_failed": "Il server non è riuscito a leggere o salvare l'elenco. Riprova più tardi." + } + }, "shared_accounts": { "title": "Condivisi con me", "description": "Account di gruppo e condivisi che puoi gestire. Selezionane uno per modificarne filtri, risponditore automatico, calendari e contatti.", diff --git a/locales/ja/common.json b/locales/ja/common.json index b5b3bb008..dbd3e4165 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "ドラッグして並べ替え", "add": "アカウントを追加" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "自分と共有", "description": "管理できるグループアカウントと共有アカウントです。1つ選択して、フィルター、不在応答、カレンダー、連絡先を編集します。", diff --git a/locales/ko/common.json b/locales/ko/common.json index c13698a8e..a550160e2 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "끌어서 순서 변경", "add": "계정 추가" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "나와 공유됨", "description": "관리할 수 있는 그룹 및 공유 계정입니다. 하나를 선택하여 필터, 부재 중 자동 응답, 캘린더, 연락처를 편집하세요.", diff --git a/locales/lv/common.json b/locales/lv/common.json index 0938cc474..4e98b1688 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Velciet, lai pārkārtotu", "add": "Pievienot kontu" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Kopīgots ar mani", "description": "Grupas un kopīgotie konti, kurus varat pārvaldīt. Atlasiet vienu, lai rediģētu tā filtrus, prombūtnes atbildētāju, kalendārus un kontaktus.", diff --git a/locales/mn/common.json b/locales/mn/common.json index 57cb87378..14e641ae2 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Дахин эрэмбэлэхийн тулд чирнэ үү", "add": "Бүртгэл нэмэх" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Надтай хуваалцсан", "description": "Бүлэг болон хуваалцсан акаунтуудыг удирдах боломжтой. Шүүлтүүр, амралтын хариулагч, хуанли, харилцагчдыг засахын тулд нэгийг нь сонгоно уу.", diff --git a/locales/nb/common.json b/locales/nb/common.json index d803d5ca7..fbb19d891 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Dra for å omorganisere", "add": "Legg til konto" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Delt med meg", "description": "Gruppekontoer og delte kontoer du kan administrere. Velg en konto for å redigere filtre, feriesvar, kalendere og kontakter.", diff --git a/locales/nl/common.json b/locales/nl/common.json index 00a6caf4d..4233ba43f 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Sleep om volgorde aan te passen", "add": "Account toevoegen" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Gedeeld met mij", "description": "Groeps- en gedeelde accounts die u kunt beheren. Selecteer er een om de filters, afwezigheidsassistent, agenda's en contacten te bewerken.", diff --git a/locales/pl/common.json b/locales/pl/common.json index 4da811ece..c964e618f 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Przeciągnij, aby zmienić kolejność", "add": "Dodaj konto" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Udostępnione mnie", "description": "Konta grupowe i udostępnione, którymi możesz zarządzać. Wybierz jedno, aby edytować jego filtry, odpowiedź wakacyjną, kalendarze i kontakty.", diff --git a/locales/pt/common.json b/locales/pt/common.json index cb0256721..57b2cd546 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Arraste para reordenar", "add": "Adicionar conta" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Compartilhado comigo", "description": "Contas de grupo e compartilhadas que você pode gerenciar. Selecione uma para editar seus filtros, resposta de ausência, calendários e contatos.", diff --git a/locales/ro/common.json b/locales/ro/common.json index fb85bab41..1215156b9 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Trageți pentru a reordona", "add": "Adăugare cont" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Partajat cu mine", "description": "Conturi de grup și conturi partajate pe care le puteți gestiona. Selectați unul pentru a-i edita filtrele, răspunsul automat de vacanță, calendarele și contactele.", diff --git a/locales/ru/common.json b/locales/ru/common.json index d6b37b4fc..32e171a5c 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Перетащите, чтобы изменить порядок", "add": "Добавить учётную запись" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Доступные мне", "description": "Групповые и общие учётные записи, которыми вы можете управлять. Выберите одну, чтобы изменить её фильтры, автоответчик, календари и контакты.", diff --git a/locales/sk/common.json b/locales/sk/common.json index 08eb583a0..e5438fac8 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "Presuňte na zmenu poradia", "add": "Pridať účet" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Zdieľané so mnou", "description": "Skupinové a zdieľané účty, ktoré môžete spravovať.", diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f69291a0..4d558d13d 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Sıralamak için sürükleyin", "add": "Hesap ekle" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Benimle paylaşılanlar", "description": "Yönetebileceğiniz grup ve paylaşılan hesaplar. Filtrelerini, tatil yanıtlayıcısını, takvimlerini ve kişilerini düzenlemek için birini seçin.", diff --git a/locales/uk/common.json b/locales/uk/common.json index b53f0f5dd..e34525254 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "Перетягніть, щоб змінити порядок", "add": "Додати обліковий запис" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "Доступні мені", "description": "Групові та спільні облікові записи, якими ви можете керувати. Виберіть один, щоб змінити його фільтри, автовідповідач, календарі та контакти.", diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index d5318d90f..ac89d5b87 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -1666,6 +1666,33 @@ "drag_handle": "拖曳以重新排序", "add": "新增帳號" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "與我共用", "description": "您可以管理的群組與共用帳號。選取一個帳號,即可編輯其篩選規則、休假自動回覆、行事曆與聯絡人。", diff --git a/locales/zh/common.json b/locales/zh/common.json index 68562edb8..8415e0bf6 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -1663,6 +1663,33 @@ "drag_handle": "拖动以重新排序", "add": "添加账户" }, + "backup": { + "title": "Account list backup", + "description": "Keep the list of accounts in this browser on the server, encrypted with a password only you know, and bring it back on another device. Mailbox passwords are not saved: you sign in to each restored account yourself.", + "status_none": "No list is saved for {account}.", + "status_saved": "A list is saved for {account}.", + "save": "Save list", + "restore": "Restore list", + "delete": "Delete saved list", + "cancel": "Cancel", + "delete_confirm": "Delete the saved account list from the server?", + "password": "Backup password", + "password_hint": "At least 10 characters. It cannot be recovered: if you forget it, save the list again with a new one.", + "restore_hint": "The password chosen when the list was saved.", + "confirm": "Confirm password", + "saved": "List saved ({count, plural, one {# account} other {# accounts}}).", + "restored": "Accounts added: {added}. Accounts already here and updated: {updated}. Open each new account from the account list to sign in.", + "skipped": "{count, plural, one {# account was} other {# accounts were}} not added: the account limit is reached.", + "deleted": "Saved list deleted.", + "errors": { + "mismatch": "The passwords do not match.", + "password_length": "Use at least 10 characters.", + "unlock_failed": "Wrong password, or the list was saved for another account.", + "owner_signin_required": "Sign in to this account again, then retry.", + "https_required": "This needs a secure (HTTPS) connection.", + "storage_failed": "The server could not read or store the list. Try again later." + } + }, "shared_accounts": { "title": "与我共享", "description": "您可以管理的群组和共享账户。选择一个以编辑其筛选器、休假自动回复、日历和联系人。", diff --git a/scripts/lite/lib.mjs b/scripts/lite/lib.mjs index 6c5ed2dba..4340a1807 100644 --- a/scripts/lite/lib.mjs +++ b/scripts/lite/lib.mjs @@ -105,6 +105,8 @@ export const LITE_API_STRING_ALLOWLIST = [ // Settings/policy - replaced by config.json / policy.json. "/api/config", "/api/admin/policy", + // lib/account-archive-client.ts - the feature is pinned off in Lite. + "/api/account-archive", // WOPI (#425) - use-wopi-status returns disabled in Lite. "/api/wopi/status", "/api/wopi/launch", diff --git a/stores/__tests__/auth-store-archive-restore.test.ts b/stores/__tests__/auth-store-archive-restore.test.ts new file mode 100644 index 000000000..50d429a84 --- /dev/null +++ b/stores/__tests__/auth-store-archive-restore.test.ts @@ -0,0 +1,97 @@ +import { beforeAll, beforeEach, expect, it, vi } from 'vitest'; +import { createConfig } from '@/lib/__tests__/fixtures/config'; + +// Accounts restored from the account list backup carry no credentials. They +// must stay listed until signed in, and opening one must lead to the sign-in +// form instead of dropping it like an account whose session ran out. +vi.mock('@/lib/jmap/client', () => { + class JMAPClient { + constructor(public serverUrl: string, public username: string, public password: string) {} + static withBearer() { throw new Error('not used'); } + async connect() {} + getSessionUsername() { return this.username; } + getUsername() { return this.username; } + async getIdentities() { return [{ id: 'a', email: this.username, name: this.username, mayDelete: false }]; } + getAuthHeader() { return 'Basic eA=='; } + onConnectionChange() {} + onRateLimit() {} + getRateLimitRemainingMs() { return 0; } + supportsContacts() { return false; } + supportsPrincipals() { return false; } + supportsVacationResponse() { return false; } + supportsCalendars() { return false; } + supportsSieve() { return false; } + disconnect() {} + getAccountId() { return 'acct'; } + } + class RateLimitError extends Error {} + return { JMAPClient, RateLimitError }; +}); +vi.mock('@/lib/stalwart/principal', () => ({ fetchPrincipalDisplayName: async () => null })); + +import * as nav from '@/lib/browser-navigation'; +import { useAuthStore } from '@/stores/auth-store'; +import { useAccountStore } from '@/stores/account-store'; +import { collectAccounts, restoreAccounts } from '@/lib/account-archive-client'; + +const signedIn = { + id: 'bob@x.test@mail.x.test', label: 'Bob', serverUrl: 'https://mail.x.test', username: 'bob@x.test', authMode: 'basic' as const, + rememberMe: true, cookieSlot: 0, displayName: 'Bob', email: 'bob@x.test', isConnected: false, hasError: false, + lastLoginAt: 1, avatarColor: '#000000', isDefault: true, +}; +const archived = [ + { username: 'bob@x.test', serverUrl: 'https://mail.x.test', label: 'Bob at work', avatarColor: '#2563eb' }, + { username: 'alice@x.test', serverUrl: 'https://mail.x.test', label: 'Alice', avatarColor: '#16a34a' }, +]; +const redirect = vi.fn(); + +beforeAll(() => { + vi.stubGlobal('fetch', vi.fn(async (input: unknown) => { + const url = String(input); + if (url.includes('/api/auth/session')) { + return new Response(JSON.stringify({ serverUrl: signedIn.serverUrl, username: signedIn.username, password: 'x' }), { status: 200 }); + } + if (url.includes('/api/config')) return new Response(JSON.stringify(createConfig()), { status: 200 }); + return new Response('{}', { status: 200 }); + })); + vi.spyOn(nav, 'replaceWindowLocation').mockImplementation(redirect); +}); + +beforeEach(() => { + redirect.mockClear(); + useAccountStore.setState({ accounts: [signedIn] as never, activeAccountId: signedIn.id, defaultAccountId: signedIn.id }); +}); + +it('adds missing accounts awaiting sign-in and relabels the ones already here', () => { + expect(collectAccounts()).toEqual([ + { username: signedIn.username, serverUrl: signedIn.serverUrl, label: 'Bob', avatarColor: '#000000' }, + ]); + expect(restoreAccounts(archived)).toEqual({ added: 1, updated: 1, skipped: 0 }); + const [bob, alice] = useAccountStore.getState().accounts; + expect(bob).toMatchObject({ label: 'Bob at work', avatarColor: '#2563eb', isDefault: true }); + expect(bob.awaitingSignIn).toBeUndefined(); + expect(alice).toMatchObject({ id: 'alice@x.test@mail.x.test', label: 'Alice', avatarColor: '#16a34a', awaitingSignIn: true, rememberMe: false }); +}); + +it('keeps a restored account through a reload and sends it to the sign-in form', async () => { + restoreAccounts(archived); + await useAuthStore.getState().checkAuth(); + expect(useAuthStore.getState().activeAccountId).toBe(signedIn.id); + expect(useAccountStore.getState().accounts.map(a => a.id)).toEqual([signedIn.id, 'alice@x.test@mail.x.test']); + + await useAuthStore.getState().switchAccount('alice@x.test@mail.x.test'); + expect(redirect).toHaveBeenCalledWith(expect.stringMatching(/\/login\?mode=add-account&username=alice%40x\.test$/)); + expect(useAccountStore.getState().accounts).toHaveLength(2); + expect(useAuthStore.getState().activeAccountId).toBe(signedIn.id); +}); + +it('stops waiting once the account is signed in', () => { + restoreAccounts(archived); + useAccountStore.getState().addAccount({ + username: 'alice@x.test', serverUrl: 'https://mail.x.test', authMode: 'basic', rememberMe: true, label: 'alice', + displayName: 'alice', email: 'alice@x.test', lastLoginAt: 2, isConnected: true, hasError: false, isDefault: false, + }); + const alice = useAccountStore.getState().getAccountById('alice@x.test@mail.x.test'); + expect(alice).toMatchObject({ label: 'Alice', avatarColor: '#16a34a', isConnected: true }); + expect(alice?.awaitingSignIn).toBeUndefined(); +}); diff --git a/stores/account-store.ts b/stores/account-store.ts index 85af0991e..d43fe42c3 100644 --- a/stores/account-store.ts +++ b/stores/account-store.ts @@ -49,6 +49,11 @@ export interface AccountEntry { errorMessage?: string; /** Whether this is the default account (loaded on app start) */ isDefault: boolean; + /** + * Brought back from the account list backup and not signed in on this + * device yet. Kept across reloads, and opening it leads to the sign-in form. + */ + awaitingSignIn?: boolean; } interface AccountState { @@ -93,6 +98,8 @@ export const useAccountStore = create()( errorMessage: undefined, lastLoginAt: entry.lastLoginAt, authMode: entry.authMode, + // Any sign-in ends the wait, whichever way it went. + awaitingSignIn: entry.awaitingSignIn, } : a ), diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 788cf7b76..eb8076bfe 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -2279,6 +2279,12 @@ export const useAuthStore = create()( const accountStore = useAccountStore.getState(); const targetAccount = accountStore.getAccountById(accountId); if (!targetAccount) return; + // Restored from the account list backup and never signed in here: + // there is no session to resume, so ask for the password instead. + if (targetAccount.awaitingSignIn && !clients.has(accountId)) { + replaceWindowLocation(`${getLocaleLoginPath()}?mode=add-account&username=${encodeURIComponent(targetAccount.username)}`); + return; + } // A switch between two already-connected accounts is done seamlessly: // the current account's client and mail stay on screen while we verify @@ -2562,6 +2568,8 @@ export const useAuthStore = create()( // overlaps the connect instead of running after it. const restoreAccount = async (account: (typeof accounts)[number]) => { if (clients.has(account.id)) return; // Already connected + // Restored from the account list backup: listed until signed in. + if (account.awaitingSignIn) return; // A password or access token login without rememberMe leaves // nothing to restore - the user logged in without persisting