diff --git a/apps/web/e2e/about.spec.ts b/apps/web/e2e/about.spec.ts index af50bd8..afe815d 100644 --- a/apps/web/e2e/about.spec.ts +++ b/apps/web/e2e/about.spec.ts @@ -3,7 +3,7 @@ import { readFileSync } from 'node:fs'; import { type Page, expect, test } from '@playwright/test'; -import { createVault, startScreen } from './create'; +import { createVault, openSettingsPage, startScreen } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9). test.setTimeout(120_000); @@ -49,7 +49,7 @@ test('shows the version and the hash of the running build, before unlock and in await startScreen(page); await createVault(page, PASSWORD); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'about'); await expectAbout(page, hash); // Opening About before unlock hides the screen without losing what was typed. diff --git a/apps/web/e2e/accessibility.spec.ts b/apps/web/e2e/accessibility.spec.ts index d39b1de..e166c67 100644 --- a/apps/web/e2e/accessibility.spec.ts +++ b/apps/web/e2e/accessibility.spec.ts @@ -62,7 +62,17 @@ test('reflows at 320 px with no horizontal scroll: start, list, editor and Setti await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); expect(await fits()).toBe(true); // Longer labels in Brazilian Portuguese. + await page.getByTestId('settings-language').click(); await page.getByRole('combobox', { name: 'Language' }).selectOption('pt-BR'); + await expect(page.getByRole('heading', { name: 'Idioma', level: 1 })).toBeVisible(); + expect(await fits()).toBe(true); + // The densest pages, in Portuguese too. + await page.getByRole('button', { name: 'Voltar', exact: true }).click(); await expect(page.getByRole('heading', { name: 'Configurações', level: 1 })).toBeVisible(); expect(await fits()).toBe(true); + for (const row of ['devices', 'backup']) { + await page.getByTestId(`settings-${row}`).click(); + expect(await fits()).toBe(true); + await page.getByRole('button', { name: 'Voltar', exact: true }).click(); + } }); diff --git a/apps/web/e2e/appearance.spec.ts b/apps/web/e2e/appearance.spec.ts index 170adc9..61cf7d8 100644 --- a/apps/web/e2e/appearance.spec.ts +++ b/apps/web/e2e/appearance.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9), on every browser. test.setTimeout(120_000); @@ -10,7 +10,7 @@ for (const theme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme: theme }); await page.goto('/'); await createVault(page, 'quartz-lantern-velvet-harbor-9214'); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'appearance'); const swatches = page.locator('fieldset span[data-accent]'); await expect(swatches).toHaveCount(6); const colors = await swatches.evaluateAll((spans) => diff --git a/apps/web/e2e/axe-devices.spec.ts b/apps/web/e2e/axe-devices.spec.ts index ad86ac3..e26da6e 100644 --- a/apps/web/e2e/axe-devices.spec.ts +++ b/apps/web/e2e/axe-devices.spec.ts @@ -45,11 +45,12 @@ async function device( return page; } -/** Goes back to Settings from wherever the vault is (on mobile, through Back). */ +/** Goes back to Settings › Devices from wherever the vault is (on mobile, through Back). */ async function settings(page: Page): Promise { const open = page.getByRole('button', { name: 'Settings' }).first(); while (!(await open.isVisible())) await button(page, 'Back').click(); await open.click(); + await page.getByTestId('settings-devices').click(); } for (const [layout, width] of WIDTHS) { diff --git a/apps/web/e2e/axe.spec.ts b/apps/web/e2e/axe.spec.ts index c786e0f..90b0924 100644 --- a/apps/web/e2e/axe.spec.ts +++ b/apps/web/e2e/axe.spec.ts @@ -90,6 +90,15 @@ for (const [layout, width] of WIDTHS) { await settings.click(); await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); await audit(page, 'Settings'); + // The densest pages (D252). + await page.getByTestId('settings-devices').click(); + await expect(page.getByRole('heading', { name: 'Devices', level: 1 })).toBeVisible(); + await audit(page, 'Settings, Devices'); + await button(page, 'Back').click(); + await page.getByTestId('settings-backup').click(); + await expect(page.getByRole('heading', { name: 'Backup', level: 1 })).toBeVisible(); + await audit(page, 'Settings, Backup'); + await button(page, 'Back').click(); // Locked: unlock, and the forgotten password. await lock(page); diff --git a/apps/web/e2e/backup-merge.spec.ts b/apps/web/e2e/backup-merge.spec.ts index a7434c8..c84a032 100644 --- a/apps/web/e2e/backup-merge.spec.ts +++ b/apps/web/e2e/backup-merge.spec.ts @@ -4,7 +4,7 @@ import { type Browser, type BrowserContext, type Page, expect, test } from '@pla import { FUTURE_CLOCK_BOUND_MS } from '@blindvault/vault'; -import { createVault, openVault, startScreen } from './create'; +import { createVault, openSettingsPage, openVault, startScreen } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -42,13 +42,9 @@ async function installation(browser: Browser, logged: string[]): Promise { return page; } -async function openSettings(page: Page) { - await page.getByRole('button', { name: 'Settings' }).first().click(); -} - /** Exports from Settings with the local password and returns the file the browser received. */ async function exportBackup(page: Page, password: string): Promise { - await openSettings(page); + await openSettingsPage(page, 'backup'); await page.getByTestId('backup-password').fill(password); const [download] = await Promise.all([ page.waitForEvent('download', { timeout: 60_000 }), @@ -56,6 +52,7 @@ async function exportBackup(page: Page, password: string): Promise { ]); const bytes = await readFile(await download.path()); await button(page, 'Back').click(); + await button(page, 'Back').click(); return bytes; } @@ -70,7 +67,7 @@ async function setPassword(page: Page, value: string) { /** Settings › Merge a backup: the file, the Recovery Phrase, then the confirmation. */ async function openForMerge(page: Page, file: Buffer, words: readonly string[]) { // After a merge, Settings still shows it, with no Settings button of its own. - if (!(await page.getByTestId('merge-start').isVisible())) await openSettings(page); + if (!(await page.getByTestId('merge-start').isVisible())) await openSettingsPage(page, 'merge'); await page.getByTestId('merge-start').click(); await page.getByTestId('restore-file').setInputFiles({ name: 'blindvault-backup.bvault', @@ -128,6 +125,7 @@ test('merges a backup made on another installation, keeping the losing edit in h // B's edit won; A's own edit is in the history, above the first state. await button(a, 'Back').click(); + await button(a, 'Back').click(); await button(a, 'ACME').click(); await button(a, 'Show').click(); await expect(a.getByTestId('secret-Password')).toHaveText(ON_B); @@ -221,7 +219,7 @@ test('a merge of a backup opened by its export password recommends Check Recover const logged: string[] = []; const a = await installation(browser, logged); await createVault(a, PASSWORD); - await openSettings(a); + await openSettingsPage(a, 'backup'); await a.getByTestId('backup-protect').check(); await button(a, 'Generate a passphrase').click(); const passphrase = await a.getByTestId('export-password').inputValue(); @@ -235,6 +233,8 @@ test('a merge of a backup opened by its export password recommends Check Recover await expect(a.getByTestId('after-restore')).toHaveCount(0); // Its own backup merges with nothing to change; the export password opened it. + await button(a, 'Back').click(); + await a.getByTestId('settings-merge').click(); await a.getByTestId('merge-start').click(); await a.getByTestId('restore-file').setInputFiles({ name: 'blindvault-backup.bvault', @@ -273,7 +273,7 @@ test.describe('devices a merge brings', () => { /** A pairs a new installation, same network only; A ends back in its vault. */ async function pair(browser: Browser, a: Page, logged: string[]) { - await openSettings(a); + await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD); @@ -300,6 +300,7 @@ test.describe('devices a merge brings', () => { await expect(a.getByTestId('pairing-done')).toBeVisible({ timeout: 60_000 }); await button(a, 'Close').click(); await button(a, 'Back').click(); + await button(a, 'Back').click(); } test('a merge names a device this one never saw, then one retired elsewhere, as a sign of the Master Key held by someone else (§1.6, D182)', async ({ @@ -339,11 +340,12 @@ test.describe('devices a merge brings', () => { await expect(result).not.toContainText('Devices now retired'); // A retires the paired device; its next backup tells C. - await openSettings(a); + await openSettingsPage(a, 'devices'); await a.getByTestId('device-retire').click(); await a.getByRole('button', { name: /^Retire / }).click(); await expect(a.getByTestId('device')).toHaveCount(1); await button(a, 'Back').click(); + await button(a, 'Back').click(); const retired = await exportBackup(a, PASSWORD); await openForMerge(c, retired, words); diff --git a/apps/web/e2e/backup.spec.ts b/apps/web/e2e/backup.spec.ts index 8ae589f..7c3923b 100644 --- a/apps/web/e2e/backup.spec.ts +++ b/apps/web/e2e/backup.spec.ts @@ -4,7 +4,7 @@ import { RecordType } from '@blindvault/crypto'; import { type Page, expect, test } from '@playwright/test'; import { corruptRecords } from './corrupt'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9); an export derives twice. test.setTimeout(180_000); @@ -43,7 +43,7 @@ test('exports a backup, asking for the local password again, then one under a ge await page.getByTestId('input-Password').fill(SECRET); await button(page, 'Save').click(); await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible(); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'backup'); await expect( page.getByText( 'A backup is an encrypted file with a full copy of this vault. Your Recovery Phrase always opens it. Keep copies away from this device, so you can restore if you lose it.', @@ -140,7 +140,7 @@ test('a record that fails verification makes no backup, and the export names it await page.getByTestId('item-title').fill('ACME'); await button(page, 'Save').click(); await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible(); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'backup'); // The export reads every record again from storage and verifies it (D181). expect(await corruptRecords(page, RecordType.item)).toBeGreaterThan(0); let downloaded = false; diff --git a/apps/web/e2e/clock-behind.spec.ts b/apps/web/e2e/clock-behind.spec.ts index 8314744..c54d4a3 100644 --- a/apps/web/e2e/clock-behind.spec.ts +++ b/apps/web/e2e/clock-behind.spec.ts @@ -2,7 +2,7 @@ import { type Page, expect, test } from '@playwright/test'; import { FUTURE_CLOCK_BOUND_MS } from '@blindvault/vault'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // The device clock goes back after the vault was written ahead (§9.2, D73, D149, D221): the vault is // read-only until the clock catches up, every screen that writes says why, and writing resumes on @@ -73,7 +73,7 @@ test('a clock gone back makes the vault read-only, each screen says why, and wri await expect(button(page, 'Edit')).toHaveCount(0); // Settings: no pairing, no re-admission and no new Recovery Phrase while read-only. - await button(page, 'Settings').click(); + await openSettingsPage(page, 'devices'); await expect(page.getByTestId('invite')).toContainText( 'The device clock went back, so the vault is read-only: pairing comes back once writing does.', ); @@ -81,11 +81,14 @@ test('a clock gone back makes the vault read-only, each screen says why, and wri await expect(page.getByTestId('readmit-device')).toContainText( 'The device clock went back, so the vault is read-only: re-admission comes back once writing does.', ); + await button(page, 'Back').click(); + await page.getByTestId('settings-recovery').click(); await expect(page.getByTestId('regenerate')).toContainText( "This device's clock went back, so the vault is read-only. The Recovery Phrase can be regenerated from", ); await expect(page.getByTestId('regenerate-start')).toHaveCount(0); await button(page, 'Back').click(); + await button(page, 'Back').click(); // A minute before writing resumes: within the bound, so only the date is shown. await page.clock.setSystemTime(UNTIL - MINUTE); diff --git a/apps/web/e2e/create.ts b/apps/web/e2e/create.ts index 6b5eaad..b473c5f 100644 --- a/apps/web/e2e/create.ts +++ b/apps/web/e2e/create.ts @@ -84,3 +84,32 @@ export async function createVault(page: Page, password: string): Promise` test id. */ +export type SettingsPageName = + | 'appearance' + | 'language' + | 'locking' + | 'password' + | 'passkey' + | 'recovery' + | 'devices' + | 'backup' + | 'merge' + | 'import' + | 'storage' + | 'refound' + | 'erase' + | 'about'; + +/** Opens Settings from the vault, then one of its pages. */ +export async function openSettingsPage(page: Page, name: SettingsPageName): Promise { + await page.getByRole('button', { name: 'Settings', exact: true }).first().click(); + await page.getByTestId(`settings-${name}`).click(); +} + +/** From a page of Settings, back to the index and on to another page. */ +export async function switchSettingsPage(page: Page, name: SettingsPageName): Promise { + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByTestId(`settings-${name}`).click(); +} diff --git a/apps/web/e2e/device-rename.spec.ts b/apps/web/e2e/device-rename.spec.ts index 1b9acf1..bdedf51 100644 --- a/apps/web/e2e/device-rename.spec.ts +++ b/apps/web/e2e/device-rename.spec.ts @@ -1,6 +1,6 @@ import { expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Creation and unlock each derive Argon2id at about 1 s (D9). test.setTimeout(120_000); @@ -12,7 +12,7 @@ test('renames this device from Settings, refusing an empty or too long name, and }) => { await page.goto('/'); await createVault(page, PASSWORD); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'devices'); const device = page.getByTestId('devices').getByTestId('device'); await device.getByTestId('device-rename').click(); @@ -33,7 +33,7 @@ test('renames this device from Settings, refusing an empty or too long name, and await page.getByTestId('unlock-password').fill(PASSWORD); await page.getByRole('button', { name: 'Unlock' }).click(); await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'devices'); await expect( page.getByTestId('devices').getByTestId('device').locator('span.font-medium'), ).toHaveText('Work laptop (this device)'); diff --git a/apps/web/e2e/erase.spec.ts b/apps/web/e2e/erase.spec.ts index 833c368..960d34f 100644 --- a/apps/web/e2e/erase.spec.ts +++ b/apps/web/e2e/erase.spec.ts @@ -1,6 +1,6 @@ import { type Page, expect, test } from '@playwright/test'; -import { createVault, startScreen } from './create'; +import { createVault, startScreen, openSettingsPage } from './create'; // Erase (§7, D41, D154, D183, D205): the warning, the Device record advice, the confirmation, then // no vault. Creation derives Argon2id at about 1 s (D9). @@ -45,7 +45,7 @@ test('erases the unlocked vault from Settings, saying it is the only device (D11 const words = await createVault(page, PASSWORD); expect(await databases(page)).toHaveLength(1); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'erase'); await expect(page.getByText(INTRO)).toBeVisible(); await button(page, 'Erase this vault from this device…').click(); const warning = page.getByTestId('erase-warning'); @@ -54,6 +54,9 @@ test('erases the unlocked vault from Settings, saying it is the only device (D11 // The backup is one click away: the export password field takes the cursor. await button(page, 'Make a backup now').click(); await expect(page.getByTestId('backup-password')).toBeFocused(); + await button(page, 'Back').click(); + await page.getByTestId('settings-erase').click(); + await button(page, 'Erase this vault from this device…').click(); await confirmErase(page); await startScreen(page); @@ -114,7 +117,7 @@ test('a second app window that takes over an erased vault shows the start screen await second.goto('/'); await expect(second.getByText('BlindVault is open in another window.')).toBeVisible(); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'erase'); await button(page, 'Erase this vault from this device…').click(); await confirmErase(page); await startScreen(page); diff --git a/apps/web/e2e/feedback.spec.ts b/apps/web/e2e/feedback.spec.ts index 2e6d103..f6722db 100644 --- a/apps/web/e2e/feedback.spec.ts +++ b/apps/web/e2e/feedback.spec.ts @@ -50,7 +50,8 @@ test('the empty vault shows its first steps, each opening where it is done (D250 await expect(steps.getByRole('img', { name: 'Done' })).toBeVisible(); await steps.getByRole('button', { name: 'Import', exact: true }).click(); - await expect(page.getByRole('heading', { name: 'Import', exact: true, level: 2 })).toBeFocused(); + await expect(page.getByRole('heading', { name: 'Import', exact: true, level: 1 })).toBeFocused(); + await button(page, 'Back').click(); await button(page, 'Back').click(); await steps.getByRole('button', { name: 'Add an item', exact: true }).click(); diff --git a/apps/web/e2e/form-history.spec.ts b/apps/web/e2e/form-history.spec.ts index 02bafef..b959f22 100644 --- a/apps/web/e2e/form-history.spec.ts +++ b/apps/web/e2e/form-history.spec.ts @@ -1,6 +1,12 @@ import { type Page, expect, test } from '@playwright/test'; -import { answerConfirmation, openVault, readPhrase } from './create'; +import { + answerConfirmation, + openSettingsPage, + openVault, + readPhrase, + switchSettingsPage, +} from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9), on every browser. test.setTimeout(180_000); @@ -121,9 +127,10 @@ test('keeps vault content out of the form history and the spellchecker (D174, D1 await page.getByTestId('search').fill('harbor'); await expectNoneKept(page); await page.getByTestId('search').fill(''); - await button(page, 'Settings').click(); + await openSettingsPage(page, 'password'); await expectNoneKept(page); await expect(page.getByText(BROWSER_SAVE)).toBeVisible(); + await switchSettingsPage(page, 'backup'); // The export password fields, with a generated passphrase shown as text (D181). await page.getByTestId('backup-protect').check(); await button(page, 'Generate a passphrase').click(); diff --git a/apps/web/e2e/import.spec.ts b/apps/web/e2e/import.spec.ts index 898cc9e..2ca2f9c 100644 --- a/apps/web/e2e/import.spec.ts +++ b/apps/web/e2e/import.spec.ts @@ -1,6 +1,6 @@ import { type Page, expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; import { storedPlaintext } from './stored'; // Import (overview §15, D123, D125, D146): preview, one commit into a landing folder, the delete @@ -39,7 +39,7 @@ function exportFile(encrypted: boolean) { } async function preview(page: Page) { - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'import'); await page.getByTestId('import-start').click(); await page.getByTestId('import-source').selectOption('bitwarden'); await expect(page.getByText('holds every secret in plaintext')).toBeVisible(); @@ -76,6 +76,7 @@ test('imports a Bitwarden export into a landing folder in one commit, after a pr ); await expect(page.getByTestId('import-result')).toContainText('Delete bitwarden_export.json now'); + await button(page, 'Back').click(); await button(page, 'Back').click(); await page.getByRole('button', { name: FOLDER }).click(); await expect(button(page, 'Wifi')).toBeVisible(); diff --git a/apps/web/e2e/kept-aside.spec.ts b/apps/web/e2e/kept-aside.spec.ts index 0b4b4d9..66db950 100644 --- a/apps/web/e2e/kept-aside.spec.ts +++ b/apps/web/e2e/kept-aside.spec.ts @@ -2,7 +2,7 @@ import { RecordType } from '@blindvault/crypto'; import { type Page, expect, test } from '@playwright/test'; import { corruptRecords } from './corrupt'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Records that fail verification at unlock are kept aside (D181): the vault opens, and each screen // that would hand them on or rely on them says why instead (D45, D202, D207), as does the @@ -38,7 +38,7 @@ test('records kept aside at unlock block pairing and re-admission, a damaged Rec await button(page, 'Unlock').click(); await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); - await button(page, 'Settings').click(); + await openSettingsPage(page, 'devices'); await expect(page.getByTestId('invite')).toContainText( 'This device holds a record it cannot read: pairing would hand it on. Re-found the vault or restore a backup.', ); @@ -46,12 +46,16 @@ test('records kept aside at unlock block pairing and re-admission, a damaged Rec await expect(page.getByTestId('readmit-device')).toContainText( 'This device holds a record it cannot read: re-admission would hand it on. Re-found the vault or restore a backup.', ); + await button(page, 'Back').click(); + await page.getByTestId('settings-recovery').click(); await expect( page.getByText( 'The Recovery Envelope on this device is damaged, so the Recovery Phrase cannot be checked. Regenerate the Recovery Phrase to replace it.', ), ).toBeVisible(); + await button(page, 'Back').click(); + await page.getByTestId('settings-refound').click(); const refound = page.getByTestId('refound'); await refound.getByTestId('refound-start').click(); await expect(refound.getByTestId('refound-corrupt')).toHaveText( diff --git a/apps/web/e2e/late-code.spec.ts b/apps/web/e2e/late-code.spec.ts index 6a68078..95c9b2f 100644 --- a/apps/web/e2e/late-code.spec.ts +++ b/apps/web/e2e/late-code.spec.ts @@ -1,6 +1,6 @@ import { type Request, expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9); this test derives five times. test.setTimeout(180_000); @@ -33,12 +33,14 @@ test('loads no script, worker or WebAssembly after the first unlock (D142)', asy }); // The strength check and two derivations. - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'password'); await page.getByTestId('current-password').fill(PASSWORD); await page.getByTestId('new-password').fill(NEXT); await page.getByTestId('confirm-password').fill(NEXT); await page.getByRole('button', { name: 'Change password' }).click(); await expect(page.getByText('Password changed.')).toBeVisible({ timeout: 30_000 }); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByTestId('settings-backup').click(); // An export under an export password: its strength check and two more derivations. await page.getByTestId('backup-protect').check(); diff --git a/apps/web/e2e/layout.spec.ts b/apps/web/e2e/layout.spec.ts index 3cc2149..69cbccc 100644 --- a/apps/web/e2e/layout.spec.ts +++ b/apps/web/e2e/layout.spec.ts @@ -97,9 +97,15 @@ for (const [layout, width] of WIDTHS) { // Mobile has Settings in its bottom navigation, below the lists. if (layout === 'mobile') await page.getByRole('button', { name: 'Back', exact: true }).click(); await page.getByRole('button', { name: 'Settings', exact: true }).first().click(); - await expect(page.getByTestId('device-storage')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); await expect(page.getByTestId('lock-state')).toHaveText('Unlocked'); await noOverflow(page, 'Settings'); + // The densest pages (D252). + for (const row of ['devices', 'backup', 'storage']) { + await page.getByTestId(`settings-${row}`).click(); + await noOverflow(page, `Settings, ${row}`); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + } }); } @@ -200,9 +206,12 @@ test("goes one screen back on the browser's back, through one entry that carries // Settings on desktop goes back the same way. await resize(page, 1280); await page.getByRole('button', { name: 'Settings', exact: true }).first().click(); + await page.getByTestId('settings-storage').click(); await expect(page.getByTestId('device-storage')).toBeVisible(); await page.goBack(); - await expect(page.getByTestId('device-storage')).toBeHidden(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); + await page.goBack(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeHidden(); await expect(page.getByRole('button', { name: 'New item', exact: true })).toBeVisible(); expect(new URL(page.url()).pathname).toBe('/'); @@ -341,8 +350,13 @@ test.describe('on a coarse pointer', () => { expect(await smallTargets(page), `${layout}: the editor and its generator`).toEqual([]); await page.getByRole('button', { name: 'Cancel', exact: true }).click(); await page.getByRole('button', { name: 'Settings', exact: true }).first().click(); - await expect(page.getByTestId('device-storage')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); expect(await smallTargets(page), `${layout}: Settings`).toEqual([]); + for (const row of ['devices', 'backup', 'storage']) { + await page.getByTestId(`settings-${row}`).click(); + expect(await smallTargets(page), `${layout}: Settings, ${row}`).toEqual([]); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + } await page.getByRole('button', { name: 'Back', exact: true }).click(); } diff --git a/apps/web/e2e/list-row.spec.ts b/apps/web/e2e/list-row.spec.ts index 2d20d85..099c0d3 100644 --- a/apps/web/e2e/list-row.spec.ts +++ b/apps/web/e2e/list-row.spec.ts @@ -1,6 +1,6 @@ import { type Page, expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // The list row (D246): avatar, a subtitle of non-secret fields, Copy for the password, and the // phone's letter groups. @@ -42,11 +42,12 @@ test('a row shows non-secret fields only and copies the password, cleared after await page.setViewportSize({ width: 1280, height: 900 }); await page.goto('/'); await createVault(page, PASSWORD); - await button(page, 'Settings').first().click(); + await openSettingsPage(page, 'locking'); await page.getByTestId('clipboardClearSeconds').fill('10'); await button(page, 'Save').click(); await expect(page.getByText('Saved.', { exact: true })).toBeVisible(); await button(page, 'Back').click(); + await button(page, 'Back').click(); await newLogin(page, 'Harbor Credit Union'); await page.getByTestId('input-Username').fill('ana.lima'); diff --git a/apps/web/e2e/logs.spec.ts b/apps/web/e2e/logs.spec.ts index 1fe8cdd..8960c36 100644 --- a/apps/web/e2e/logs.spec.ts +++ b/apps/web/e2e/logs.spec.ts @@ -1,6 +1,6 @@ import { type Page, expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9), on every browser. test.setTimeout(120_000); @@ -68,7 +68,7 @@ test('logs nothing, and no secret, through a full use (CRYPTO-INV-005, 006)', as await expect(page.getByText('Wrong password.')).toBeVisible({ timeout: 30_000 }); await unlock(page, PASSWORD); await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'password'); const change = page.getByRole('button', { name: 'Change password' }); await page.getByTestId('current-password').fill(WRONG); await page.getByTestId('new-password').fill(NEXT); diff --git a/apps/web/e2e/navigation.spec.ts b/apps/web/e2e/navigation.spec.ts index f6f3ca3..b9ae598 100644 --- a/apps/web/e2e/navigation.spec.ts +++ b/apps/web/e2e/navigation.spec.ts @@ -110,3 +110,52 @@ test('filters the list by type, with counts beside the types and the folder pane await pane.getByRole('button', { name: 'All items', exact: true }).click(); await expect(list).toHaveText(['Garden plan', 'Tax return']); }); + +test('Settings is an index of pages in groups; a page opens over it and Back returns, then leaves (D252)', async ({ + page, +}) => { + await page.setViewportSize({ width: 375, height: 800 }); + await page.goto('/'); + await createVault(page, PASSWORD); + await button(page, 'Settings').click(); + + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); + for (const group of ['Preferences', 'Security', 'Devices', 'Data', 'Danger zone']) { + await expect(page.getByRole('heading', { name: group, level: 2 })).toBeVisible(); + } + for (const row of [ + 'appearance', + 'language', + 'locking', + 'password', + 'passkey', + 'recovery', + 'devices', + 'backup', + 'merge', + 'import', + 'storage', + 'refound', + 'erase', + 'about', + ]) { + await expect(page.getByTestId(`settings-${row}`)).toBeVisible(); + } + // A row shows its current value where one helps: a fresh vault has no backup, and locks after + // the default 5 minutes (DEFAULT_PREFERENCES). + await expect(page.getByTestId('settings-backup')).toContainText('Never'); + await expect(page.getByTestId('settings-locking')).toContainText('5 min'); + + await page.getByTestId('settings-locking').click(); + await expect(page.getByRole('heading', { name: 'Locking', level: 1 })).toBeFocused(); + await expect(page.getByTestId('autoLockMinutes')).toBeVisible(); + await expect(page.getByTestId('settings-locking')).toHaveCount(0); + + await button(page, 'Back').click(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible(); + await expect(page.getByTestId('settings-locking')).toBeVisible(); + + await button(page, 'Back').click(); + await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toHaveCount(0); + await expect(button(page, 'New item')).toBeVisible(); +}); diff --git a/apps/web/e2e/onboarding.spec.ts b/apps/web/e2e/onboarding.spec.ts index 1d424cc..c0ee1fb 100644 --- a/apps/web/e2e/onboarding.spec.ts +++ b/apps/web/e2e/onboarding.spec.ts @@ -97,6 +97,7 @@ for (const width of [390, 1280] as const) { await expect(page.getByTestId('backup-password')).toBeFocused(); await expect(ready).toHaveCount(0); await button(page, 'Back').click(); + await button(page, 'Back').click(); await expect(ready).toBeVisible(); // Pairing opens with its password step already shown, the cursor in it (D234). @@ -109,6 +110,7 @@ for (const width of [390, 1280] as const) { ), ).toBeVisible(); await button(page, 'Back').click(); + await button(page, 'Back').click(); await expect(ready).toBeVisible(); // Closed: the vault, with the backup reminder, and never again after an unlock. @@ -141,6 +143,7 @@ test('"Vault ready" marks the backup done once made, and still offers it (D234)' button(page, 'Export').click(), ]); await button(page, 'Back').click(); + await button(page, 'Back').click(); await expect(backup).toHaveAttribute('data-done', 'true'); await expect(backup).toContainText('Done'); await expect(backup.getByRole('button')).toHaveText('Make a backup'); diff --git a/apps/web/e2e/pairing-qr.spec.ts b/apps/web/e2e/pairing-qr.spec.ts index ec0d04a..95a4ee1 100644 --- a/apps/web/e2e/pairing-qr.spec.ts +++ b/apps/web/e2e/pairing-qr.spec.ts @@ -1,7 +1,7 @@ import { type Browser, type Page, expect, test } from '@playwright/test'; import { deniedCamera, expectCameraReleased, fakeQrCamera, noCamera, showQr } from './camera'; -import { createVault, openVault, startScreen } from './create'; +import { createVault, openVault, startScreen, openSettingsPage } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -59,7 +59,7 @@ async function invite(a: Page): Promise { await a.getByTestId('item-title').fill(TITLE); await button(a, 'Save').click(); await expect(a.getByRole('heading', { name: TITLE })).toBeVisible(); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); diff --git a/apps/web/e2e/pairing-stun.spec.ts b/apps/web/e2e/pairing-stun.spec.ts index 027acb2..81309cd 100644 --- a/apps/web/e2e/pairing-stun.spec.ts +++ b/apps/web/e2e/pairing-stun.spec.ts @@ -1,6 +1,6 @@ import { type Browser, type Page, expect, test } from '@playwright/test'; -import { createVault, startScreen } from './create'; +import { createVault, startScreen, openSettingsPage } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -64,7 +64,7 @@ test('a STUN list of its own: refused when invalid, saved, used by the pairing, }) => { const a = await device(browser); await createVault(a, PASSWORD_A); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); const hint = a.getByTestId('same-network-hint'); await expect(hint).toContainText('(stun.l.google.com, stun.cloudflare.com)'); @@ -144,7 +144,7 @@ test('an unreadable stored list: the same network only, locked on, and the pairi const b = await device(browser); await createVault(a, PASSWORD_A); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); diff --git a/apps/web/e2e/pairing.spec.ts b/apps/web/e2e/pairing.spec.ts index 5a8bbad..0a1d966 100644 --- a/apps/web/e2e/pairing.spec.ts +++ b/apps/web/e2e/pairing.spec.ts @@ -2,7 +2,13 @@ import { RecordType } from '@blindvault/crypto'; import { type Browser, type Page, expect, test } from '@playwright/test'; import { corruptRecords } from './corrupt'; -import { createVault, openVault, startScreen } from './create'; +import { + createVault, + openSettingsPage, + openVault, + startScreen, + switchSettingsPage, +} from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -54,7 +60,7 @@ async function invite(a: Page): Promise { await button(a, 'Save').click(); await expect(a.getByRole('heading', { name: TITLE })).toBeVisible(); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); @@ -123,7 +129,7 @@ test('pairs two devices: the same number on both, then B holds the vault (§8.4, await expect(a.getByTestId('device-last-sync')).toContainText('Last synced with this device: '); await expect(a.getByTestId('device-last-sync')).not.toContainText('never'); await expect(a.getByTestId('device-pending')).toHaveCount(0); - await button(b, 'Settings').click(); + await openSettingsPage(b, 'devices'); await expect(b.getByTestId('device')).toHaveCount(2); await expect(b.getByTestId('device-last-sync')).not.toContainText('never'); @@ -134,12 +140,13 @@ test('pairs two devices: the same number on both, then B holds the vault (§8.4, await expect(b.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); // Erasing B names its Device record, to retire from A, before and after (D205). - await button(b, 'Settings').click(); + await openSettingsPage(b, 'devices'); const own = b.getByTestId('device').filter({ hasText: '(this device)' }); const nameB = ((await own.locator('span.font-medium').textContent()) ?? '') .replace(' (this device)', '') .trim(); expect(nameB).not.toBe(''); + await switchSettingsPage(b, 'erase'); const named = `Afterwards, retire this device's Device record, "${nameB}", from another device: until then they keep offering it as a sync peer.`; await button(b, 'Erase this vault from this device…').click(); await expect(b.getByTestId('erase-warning').getByText(named, { exact: true })).toBeVisible(); @@ -162,6 +169,7 @@ test('a clock gone back far on a paired device offers replacing its vault by pai const now = await a.evaluate(() => Date.now()); await a.clock.setSystemTime(now + 400 * 24 * 60 * 60 * 1000); await button(a, 'Back').click(); + await button(a, 'Back').click(); await button(a, 'New item').click(); await button(a, 'Login').click(); await a.getByTestId('item-title').fill('Ahead'); @@ -260,19 +268,23 @@ test('a hidden-app lock at 0 says it locks at once, in Settings, the export and }) => { const a = await device(browser); await createVault(a, PASSWORD_A); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'locking'); const immediate = 'At 0 the vault locks as soon as the app is hidden, including during a file picker or passkey prompt on some systems.'; const exportHidden = 'The vault locks as soon as the app is hidden: if the browser hides it while it saves the file, the vault locks, and the export must be repeated.'; await expect(a.getByText(immediate)).toHaveCount(0); + await switchSettingsPage(a, 'backup'); await expect(a.getByText(exportHidden)).toHaveCount(0); + await switchSettingsPage(a, 'locking'); await a.getByTestId('hiddenLockSeconds').fill('0'); await expect(a.getByText(immediate)).toBeVisible(); await button(a, 'Save').click(); await expect(a.getByText('Saved.')).toBeVisible(); + await switchSettingsPage(a, 'backup'); await expect(a.getByText(exportHidden)).toBeVisible(); + await switchSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); @@ -280,17 +292,20 @@ test('a hidden-app lock at 0 says it locks at once, in Settings, the export and await expect(a.getByTestId('pairing-text')).not.toHaveValue('', { timeout: 30_000 }); await expect( a.getByText( - 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock below.', + 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.', ), ).toBeVisible(); await a.getByTestId('pairing-cancel').click(); + await switchSettingsPage(a, 'locking'); await a.getByTestId('hiddenLockSeconds').fill('30'); await expect(a.getByText(immediate)).toHaveCount(0); await button(a, 'Save').click(); await expect(a.getByText('Saved.')).toBeVisible(); + await switchSettingsPage(a, 'backup'); await expect(a.getByText(exportHidden)).toHaveCount(0); - // Cancel left the password step open. + await switchSettingsPage(a, 'devices'); + await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); await button(a, 'Start pairing').click(); diff --git a/apps/web/e2e/passkey.spec.ts b/apps/web/e2e/passkey.spec.ts index 9099636..47ee847 100644 --- a/apps/web/e2e/passkey.spec.ts +++ b/apps/web/e2e/passkey.spec.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { type CDPSession, type Page, expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9), on every browser. test.setTimeout(120_000); @@ -45,7 +45,7 @@ async function create(page: Page) { } async function setUp(page: Page, password = PASSWORD) { - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'passkey'); await expect(page.getByText(/needs only its screen lock, fingerprint or face/)).toBeVisible(); await page.getByTestId('passkey-password').fill(password); await page.getByRole('button', { name: 'Set up passkey', exact: true }).click(); @@ -79,7 +79,7 @@ test('sets up a device-bound passkey and unlocks with it (§5.5, D75)', async ({ await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); // Removed: no longer offered. - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'passkey'); await page.getByRole('button', { name: 'Remove passkey', exact: true }).click(); await expect( page.getByText('Passkey removed. Your passkey manager was asked to delete it.', { @@ -197,6 +197,8 @@ test('with no way to ask the manager, removing, replacing or erasing says to del // Replaced with its own backup while a passkey is set up. await setUpAgain(page); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByTestId('settings-backup').click(); await page.getByTestId('backup-password').fill(PASSWORD); const [download] = await Promise.all([ page.waitForEvent('download', { timeout: 60_000 }), @@ -227,8 +229,10 @@ test('with no way to ask the manager, removing, replacing or erasing says to del ).toBeVisible(); // Erased while a passkey is set up. - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'passkey'); await setUpAgain(page); + await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByTestId('settings-erase').click(); await page .getByRole('button', { name: 'Erase this vault from this device…', exact: true }) .click(); diff --git a/apps/web/e2e/phrase-regeneration.spec.ts b/apps/web/e2e/phrase-regeneration.spec.ts index 4243713..df193df 100644 --- a/apps/web/e2e/phrase-regeneration.spec.ts +++ b/apps/web/e2e/phrase-regeneration.spec.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { type Page, expect, test } from '@playwright/test'; -import { answerConfirmation, createVault } from './create'; +import { answerConfirmation, createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9); exports and the regeneration derive // again. @@ -66,10 +66,14 @@ test('regenerates the Recovery Phrase, keeping the vault, and asks for a new bac await page.getByTestId('item-title').fill('ACME'); await button(page, 'Save').click(); await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible(); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'recovery'); const oldId = await currentId(page); expect(oldId).toMatch(/^[0-9a-f]{8}$/); + await button(page, 'Back').click(); + await page.getByTestId('settings-backup').click(); await exportBackup(page); + await button(page, 'Back').click(); + await page.getByTestId('settings-recovery').click(); await expect(page.getByTestId('recovery-no-backup')).toHaveCount(0); await expect( page.getByText( @@ -118,7 +122,7 @@ test('regenerates the Recovery Phrase, keeping the vault, and asks for a new bac ).toBeVisible(); // The backup made before opens only with the previous phrase. await expect(page.getByTestId('recovery-no-backup')).toHaveText( - 'No backup made on this device opens with the current Recovery Phrase yet: make one in Backup, above.', + 'No backup made on this device opens with the current Recovery Phrase yet: make one in Settings › Backup.', ); await check(page, words); @@ -131,10 +135,13 @@ test('regenerates the Recovery Phrase, keeping the vault, and asks for a new bac await expect(page.getByTestId('backup-password')).toBeFocused(); await exportBackup(page); await expect(page.getByText(`It opens with the Recovery Phrase ${newId}`)).toBeVisible(); + await button(page, 'Back').click(); + await page.getByTestId('settings-recovery').click(); await expect(page.getByTestId('recovery-no-backup')).toHaveCount(0); // The vault contents did not change. await button(page, 'Back').click(); + await button(page, 'Back').click(); await expect(page.getByText('ACME').first()).toBeVisible(); const traces = [...old, ...words].flatMap((w, i, all) => i + 1 < all.length ? [`${w} ${all[i + 1] ?? ''}`] : [], @@ -147,10 +154,12 @@ test('pauses the inactivity lock while the new phrase is shown, and resumes it o }) => { await page.goto('/'); await createVault(page, PASSWORD); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'locking'); await page.getByTestId('autoLockMinutes').fill('1'); await button(page, 'Save').first().click(); await expect(page.getByText('Saved.')).toBeVisible(); + await button(page, 'Back').click(); + await page.getByTestId('settings-recovery').click(); const words = await newPhrase(page); await page.clock.install(); // The inactivity timer restarted after the phrase screen opened runs on the fake clock. diff --git a/apps/web/e2e/readmission.spec.ts b/apps/web/e2e/readmission.spec.ts index 1f23d17..fe3856e 100644 --- a/apps/web/e2e/readmission.spec.ts +++ b/apps/web/e2e/readmission.spec.ts @@ -4,7 +4,7 @@ import { RecordType } from '@blindvault/crypto'; import { type Browser, type Page, expect, test } from '@playwright/test'; import { corruptRecords } from './corrupt'; -import { createVault, openVault, startScreen } from './create'; +import { createVault, openSettingsPage, openVault, startScreen } from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -56,7 +56,7 @@ async function addItem(page: Page, title: string): Promise { /** Settings › Devices › Re-admission, same network only; `first` shows its text first. */ async function startReadmission(page: Page, first: boolean): Promise { - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'devices'); await button(page, 'Re-admit with another device of this vault…').click(); await page.getByTestId('readmit-device').getByTestId('pairing-same-network').check(); await page.getByTestId(first ? 'readmission-first' : 'readmission-paste').click(); @@ -89,7 +89,7 @@ async function restored(browser: Browser, logged: string[]) { const a = await device(browser, logged); const words = await createVault(a, PASSWORD_A); await addItem(a, 'Harbor Bank'); - await a.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(a, 'backup'); await a.getByTestId('backup-password').fill(PASSWORD_A); const [download] = await Promise.all([ a.waitForEvent('download', { timeout: 60_000 }), @@ -97,6 +97,7 @@ async function restored(browser: Browser, logged: string[]) { ]); const backup = await readFile(await download.path()); await button(a, 'Back').click(); + await button(a, 'Back').click(); // B: a fresh restore of A's backup, unregistered, with an item of its own. const b = await device(browser, logged); @@ -143,6 +144,7 @@ test('re-admits a restored device: both know each other and hold every item (§9 await expect(page.getByTestId('device-last-sync')).not.toContainText('never'); await expect(page.getByTestId('devices-readmit')).toHaveCount(0); await button(page, 'Back').click(); + await button(page, 'Back').click(); await expect(page.getByRole('button', { name: /Harbor Bank/ })).toBeVisible(); await expect(page.getByRole('button', { name: /Copper Mail/ })).toBeVisible(); } @@ -158,7 +160,7 @@ test('a pairing answered by a device of the vault is refused, and a restored dev const { a, b } = await restored(browser, logged); // A invites; B answers from its re-admission, as a device that already holds the vault. - await a.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); @@ -248,6 +250,7 @@ test('numbers said to differ stop the re-admission on both, and nothing is merge await expect(a.getByTestId('device')).toHaveCount(1); await expect(b.getByTestId('devices-readmit')).toBeVisible(); await button(a, 'Back').click(); + await button(a, 'Back').click(); await expect(a.getByRole('button', { name: /Copper Mail/ })).toHaveCount(0); }); @@ -275,5 +278,6 @@ test('a stored record of A that fails verification stops the re-admission on bot 'Pairing not completed: this device never got the confirmation that the other one kept the vault.', ); await button(a, 'Back').click(); + await button(a, 'Back').click(); await expect(a.getByRole('button', { name: /Copper Mail/ })).toHaveCount(0); }); diff --git a/apps/web/e2e/recovery-phrase.spec.ts b/apps/web/e2e/recovery-phrase.spec.ts index ffde93b..ad002fb 100644 --- a/apps/web/e2e/recovery-phrase.spec.ts +++ b/apps/web/e2e/recovery-phrase.spec.ts @@ -1,7 +1,7 @@ import { type Page, expect, test } from '@playwright/test'; import { formAlert } from './alerts'; -import { answerConfirmation, createVault, readPhrase } from './create'; +import { answerConfirmation, createVault, readPhrase, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9), on every browser. test.setTimeout(120_000); @@ -131,7 +131,7 @@ test('checks the Recovery Phrase while unlocked, keeping the typed words nowhere page.on('console', (message) => logged.push(message.text())); await page.goto('/'); const words = await createVault(page, PASSWORD); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'recovery'); await expect(page.getByText(/The current Recovery Phrase was created on/)).toBeVisible(); const field = page.getByTestId('check-phrase'); const check = async (typed: string) => { diff --git a/apps/web/e2e/refounding.spec.ts b/apps/web/e2e/refounding.spec.ts index 143e5be..11eadec 100644 --- a/apps/web/e2e/refounding.spec.ts +++ b/apps/web/e2e/refounding.spec.ts @@ -1,6 +1,6 @@ import { type Page, expect, test } from '@playwright/test'; -import { answerConfirmation, createVault } from './create'; +import { answerConfirmation, createVault, openSettingsPage } from './create'; // Creation, the proof of the user and the new password each derive Argon2id at about 1 s (D9). test.setTimeout(240_000); @@ -35,8 +35,10 @@ test('re-founds the vault: warnings, proof, new phrase and password, and only th await newLogin(page, 'Discarded'); await button(page, 'More actions').click(); await button(page, 'Move to trash').click(); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'recovery'); const before = (await page.getByTestId('recovery-current').textContent()) ?? ''; + await button(page, 'Back').click(); + await page.getByTestId('settings-refound').click(); // The warnings, and the confirmation they need (D46, D72). const refound = page.getByTestId('refound'); @@ -102,17 +104,24 @@ test('re-founds the vault: warnings, proof, new phrase and password, and only th timeout: 60_000, }, ); + // The new vault also shows the backup reminder, with its own Dismiss (D188). + await expect(page.getByTestId('backup-reminder')).toBeVisible(); + await page.getByTestId('refounded').getByRole('button', { name: 'Dismiss' }).click(); + await expect(page.getByTestId('refounded')).toHaveCount(0); + expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(1); + await button(page, 'Back').click(); + await page.getByTestId('settings-devices').click(); await expect(page.getByTestId('devices').getByTestId('device')).toHaveCount(1); await expect(page.getByTestId('devices').getByTestId('device')).toContainText('(this device)'); + await button(page, 'Back').click(); + await page.getByTestId('settings-recovery').click(); await expect(page.getByTestId('recovery-current')).not.toHaveText(before); + await button(page, 'Back').click(); + await page.getByTestId('settings-backup').click(); await expect(page.getByTestId('last-backup')).toHaveText( 'No backup has been made on this device yet.', ); - // The new vault also shows the backup reminder, with its own Dismiss (D188). - await expect(page.getByTestId('backup-reminder')).toBeVisible(); - await page.getByTestId('refounded').getByRole('button', { name: 'Dismiss' }).click(); - await expect(page.getByTestId('refounded')).toHaveCount(0); - expect(await page.evaluate(async () => (await indexedDB.databases()).length)).toBe(1); + await button(page, 'Back').click(); await button(page, 'Back').click(); await expect(button(page, 'ACME')).toBeVisible(); await expect(button(page, 'Discarded')).toHaveCount(0); diff --git a/apps/web/e2e/replace-vault.spec.ts b/apps/web/e2e/replace-vault.spec.ts index 504596b..0f261fd 100644 --- a/apps/web/e2e/replace-vault.spec.ts +++ b/apps/web/e2e/replace-vault.spec.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { type Browser, type BrowserContext, type Page, expect, test } from '@playwright/test'; -import { createVault, openVault } from './create'; +import { createVault, openVault, openSettingsPage } from './create'; // Creation, export and replacement each derive Argon2id at about 1 s (D9). test.setTimeout(300_000); @@ -43,7 +43,7 @@ async function newItem(page: Page, title: string) { /** Exports from Settings with the local password and returns the file the browser received. */ async function exportBackup(page: Page): Promise { - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'backup'); await page.getByTestId('backup-password').fill(PASSWORD); const [download] = await Promise.all([ page.waitForEvent('download', { timeout: 60_000 }), @@ -51,6 +51,7 @@ async function exportBackup(page: Page): Promise { ]); const bytes = await readFile(await download.path()); await button(page, 'Back').click(); + await button(page, 'Back').click(); return bytes; } diff --git a/apps/web/e2e/restore.spec.ts b/apps/web/e2e/restore.spec.ts index ba6ce5e..866e9da 100644 --- a/apps/web/e2e/restore.spec.ts +++ b/apps/web/e2e/restore.spec.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { type Page, expect, test } from '@playwright/test'; -import { createVault, openVault, startScreen } from './create'; +import { createVault, openSettingsPage, openVault, startScreen } from './create'; import { fillPassword } from './password'; // Creation, export and restore each calibrate or derive Argon2id at about 1 s (D9). @@ -35,7 +35,7 @@ async function vaultWithItem(page: Page): Promise { await page.getByTestId('input-Password').fill(SECRET); await button(page, 'Save').click(); await expect(page.getByRole('heading', { name: 'ACME', level: 1 })).toBeVisible(); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'backup'); return words; } @@ -186,6 +186,7 @@ test('restores a backup by its export password, then recommends Check Recovery P await button(page, 'Check Recovery Phrase').click(); await expect(page.getByTestId('check-phrase')).toBeFocused(); await button(page, 'Back').click(); + await button(page, 'Back').click(); await expect(ready).toBeVisible(); // Closing it counts as both one-time notices: no banner repeats them. await openVault(page); diff --git a/apps/web/e2e/restored-device.spec.ts b/apps/web/e2e/restored-device.spec.ts index af80d6a..d5da717 100644 --- a/apps/web/e2e/restored-device.spec.ts +++ b/apps/web/e2e/restored-device.spec.ts @@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises'; import { type Page, expect, test } from '@playwright/test'; -import { createVault, startScreen } from './create'; +import { createVault, openSettingsPage, startScreen, switchSettingsPage } from './create'; // Creation, export and restore each calibrate or derive Argon2id at about 1 s (D9). test.setTimeout(240_000); @@ -48,7 +48,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires const logged = watchLogs(page); await page.goto('/'); const words = await createVault(page, PASSWORD); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'devices'); // The creator: its own record only, so it is the only device (D119), with nothing to retire. const devices = page.getByTestId('devices'); @@ -63,6 +63,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires .trim(); expect(name).not.toBe(''); + await switchSettingsPage(page, 'backup'); await page.getByTestId('backup-password').fill(PASSWORD); const [download] = await Promise.all([ page.waitForEvent('download', { timeout: 60_000 }), @@ -98,6 +99,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires await expect(devices.getByTestId('device')).toContainText(name); await expect(devices.getByTestId('device')).not.toContainText('(this device)'); // Erasing it now leaves no Device record of its own to retire (D205). + await switchSettingsPage(page, 'erase'); await button(page, 'Erase this vault from this device…').click(); const erase = page.getByTestId('erase-warning'); await expect( @@ -105,6 +107,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires ).toBeVisible(); await erase.getByRole('button', { name: 'Cancel', exact: true }).click(); await expect(erase).toHaveCount(0); + await switchSettingsPage(page, 'devices'); // Retiring asks for a confirmation that says what it is not; cancel writes nothing. await devices.getByTestId('device-retire').click(); @@ -134,7 +137,7 @@ test('a restored device is unregistered, is told to get re-admitted, and retires await expect(page.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 30_000 }); await expect(page.getByTestId('ready')).toHaveCount(0); await expect(page.getByTestId('readmission')).toHaveCount(0); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'devices'); await expect(devices.getByTestId('devices-retired-count')).toBeVisible(); const secrets = [PASSWORD, NEW_PASSWORD, ...words]; diff --git a/apps/web/e2e/storage-state.spec.ts b/apps/web/e2e/storage-state.spec.ts index f7465eb..1ad228a 100644 --- a/apps/web/e2e/storage-state.spec.ts +++ b/apps/web/e2e/storage-state.spec.ts @@ -3,7 +3,7 @@ import { type Page, expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Creating a vault calibrates Argon2id at about 1 s (D9). test.setTimeout(120_000); @@ -60,7 +60,7 @@ async function returnToVisible(page: Page): Promise { } async function openSettings(page: Page): Promise { - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'storage'); await expect(page.getByTestId('device-storage')).toBeVisible(); } diff --git a/apps/web/e2e/sync.spec.ts b/apps/web/e2e/sync.spec.ts index b48f4c7..4f3cdb3 100644 --- a/apps/web/e2e/sync.spec.ts +++ b/apps/web/e2e/sync.spec.ts @@ -2,7 +2,13 @@ import { RecordType } from '@blindvault/crypto'; import { type Browser, type Page, expect, test } from '@playwright/test'; import { corruptRecords } from './corrupt'; -import { answerConfirmation, createVault, startScreen } from './create'; +import { + answerConfirmation, + createVault, + openSettingsPage, + startScreen, + switchSettingsPage, +} from './create'; import { PAIRING_URL } from '../playwright.config'; import { fillPassword } from './password'; @@ -56,7 +62,7 @@ async function paired(browser: Browser) { const a = await device(browser); const b = await device(browser); await createVault(a, PASSWORD_A); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await button(a, 'Pair a device…').click(); await a.getByTestId('pairing-same-network').check(); await fillPassword(a.getByTestId('invite-password'), PASSWORD_A); @@ -82,7 +88,7 @@ async function paired(browser: Browser) { await expect(b.getByTestId('lock-state')).toHaveText('Unlocked', { timeout: 60_000 }); await expect(a.getByTestId('pairing-done')).toBeVisible({ timeout: 60_000 }); await button(a, 'Close').click(); - await button(b, 'Settings').click(); + await openSettingsPage(b, 'devices'); return { a, b }; } @@ -91,9 +97,11 @@ test('two paired devices connect from their sync codes, each with the other pick }) => { const { a, b } = await paired(browser); // B locks at once when hidden; A keeps the default delay (D93, D105). + await switchSettingsPage(b, 'locking'); await b.getByTestId('hiddenLockSeconds').fill('0'); await button(b, 'Save').click(); await expect(b.getByText('Saved.')).toBeVisible(); + await switchSettingsPage(b, 'devices'); // Each picks the other device; A shows its code first, B reads it. await a.getByTestId('device-sync').click(); @@ -117,7 +125,7 @@ test('two paired devices connect from their sync codes, each with the other pick await syncB.getByTestId('sync-read').click(); await expect( syncB.getByText( - 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock below.', + 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.', ), ).toBeVisible(); await syncB.getByTestId('pairing-paste').fill(offerText); @@ -216,6 +224,7 @@ test('A retires B, then tells it: B merges its retired record and shows the D41 await expect(b.getByTestId('readmit-device')).toContainText( 'This device was retired: it cannot be re-admitted. Replace it by pairing to sync.', ); + await switchSettingsPage(b, 'refound'); // Re-founding on B says it cannot sync first, in place of the advice to sync (D47). await b.getByTestId('refound-start').click(); const warnings = b.getByTestId('refound-warnings'); @@ -293,7 +302,10 @@ async function closed(a: Page, b: Page) { await b.getByTestId('sync-end').click(); await expect(a.getByTestId('sync-failed')).toHaveAttribute('data-reason', 'ended'); await a.getByTestId('sync-again').click(); - for (const page of [a, b]) await button(page, 'Back').click(); + for (const page of [a, b]) { + await button(page, 'Back').click(); + await button(page, 'Back').click(); + } } async function newLogin(page: Page, title: string) { @@ -315,13 +327,15 @@ test('an item created on A reaches B through a sync, and B shows the merge (D222 }) => { const { a, b } = await paired(browser); await button(a, 'Back').click(); + await button(a, 'Back').click(); await newLogin(a, 'ACME'); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await synced(a, b); await expect(b.getByTestId('merge-result')).toContainText('Merged: 1 added, 0 replaced.'); await b.getByTestId('sync-end').click(); await button(b, 'Back').click(); + await button(b, 'Back').click(); // B still shows "Vault ready" from its pairing (D198). await button(b, 'Open the vault').click(); await expect(button(b, 'ACME')).toBeVisible(); @@ -332,8 +346,9 @@ test('a record of A that fails verification stops the sync before it is sent: A }) => { const { a, b } = await paired(browser); await button(a, 'Back').click(); + await button(a, 'Back').click(); await newLogin(a, 'ACME'); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); // B lacks the item, so A reads it again from storage and verifies it before sending (D181). expect(await corruptRecords(a, RecordType.item)).toBeGreaterThan(0); @@ -352,6 +367,7 @@ test('a record of A that fails verification stops the sync before it is sent: A await b.getByTestId('sync-again').click(); await button(b, 'Back').click(); + await button(b, 'Back').click(); await button(b, 'Open the vault').click(); await expect(button(b, 'ACME')).toHaveCount(0); }); @@ -404,8 +420,9 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i }) => { const { a, b } = await paired(browser); await button(a, 'Back').click(); + await button(a, 'Back').click(); await newLogin(a, 'ACME'); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await synced(a, b); await closed(a, b); await button(b, 'Open the vault').click(); @@ -413,7 +430,7 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i // A edits first, then B, so B's is the newer write. await rename(a, 'ACME', 'ACME on A'); await rename(b, 'ACME', 'ACME on B'); - for (const page of [a, b]) await button(page, 'Settings').click(); + for (const page of [a, b]) await openSettingsPage(page, 'devices'); await synced(a, b); await expect(a.getByTestId('merge-result')).toContainText('Merged: 0 added, 1 replaced.'); await expect(b.getByTestId('merge-result')).toContainText('Merged: 0 added, 0 replaced.'); @@ -437,8 +454,9 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and }) => { const { a, b } = await paired(browser); await button(a, 'Back').click(); + await button(a, 'Back').click(); await newLogin(a, 'ACME'); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await synced(a, b); await closed(a, b); await button(b, 'Open the vault').click(); @@ -450,7 +468,7 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and await rename(b, title, `B ${String(i)}`); title = `B ${String(i)}`; } - for (const page of [a, b]) await button(page, 'Settings').click(); + for (const page of [a, b]) await openSettingsPage(page, 'devices'); await synced(a, b); const notice = 'An older edit made on this device no longer fits the history of: ACME on A.'; await expect(a.getByText(notice)).toBeVisible(); @@ -472,8 +490,9 @@ test('an edit older than a trash never brings the item back: in the trash on bot }) => { const { a, b } = await paired(browser); await button(a, 'Back').click(); + await button(a, 'Back').click(); await newLogin(a, 'ACME'); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await synced(a, b); await closed(a, b); await button(b, 'Open the vault').click(); @@ -483,7 +502,7 @@ test('an edit older than a trash never brings the item back: in the trash on bot await button(b, 'ACME').click(); await button(b, 'More actions').click(); await button(b, 'Move to trash').click(); - for (const page of [a, b]) await button(page, 'Settings').click(); + for (const page of [a, b]) await openSettingsPage(page, 'devices'); await synced(a, b); await closed(a, b); @@ -508,8 +527,9 @@ test('an item trashed on A is in the trash of both with the same purge date, off }) => { const { a, b } = await paired(browser); await button(a, 'Back').click(); + await button(a, 'Back').click(); await newLogin(a, 'ACME'); - await button(a, 'Settings').click(); + await openSettingsPage(a, 'devices'); await synced(a, b); await closed(a, b); await button(b, 'Open the vault').click(); @@ -517,8 +537,8 @@ test('an item trashed on A is in the trash of both with the same purge date, off await button(a, 'ACME').click(); await button(a, 'More actions').click(); await button(a, 'Move to trash').click(); - await button(a, 'Settings').click(); - await button(b, 'Settings').click(); + await openSettingsPage(a, 'devices'); + await openSettingsPage(b, 'devices'); await synced(a, b); await closed(a, b); @@ -564,16 +584,22 @@ test('a phrase regenerated on A reaches B in one sync, which tells its user and browser, }) => { const { a, b } = await paired(browser); + await switchSettingsPage(b, 'backup'); await b.getByTestId('backup-password').fill(PASSWORD_B); await Promise.all([b.waitForEvent('download', { timeout: 60_000 }), button(b, 'Export').click()]); + await switchSettingsPage(b, 'recovery'); await expect(b.getByTestId('recovery-no-backup')).toHaveCount(0); + await switchSettingsPage(a, 'recovery'); const id = await regenerate(a, PASSWORD_A); + await switchSettingsPage(a, 'devices'); + await switchSettingsPage(b, 'devices'); await synced(a, b); await expect(b.getByTestId('merge-result')).toContainText( `The Recovery Phrase was regenerated on another device: the new one, ${id},`, ); await expect(a.getByTestId('merge-result')).not.toContainText('Recovery Phrase'); + await switchSettingsPage(b, 'recovery'); expect(await currentId(b)).toBe(id); // B's backup was made for the previous phrase. await expect(b.getByTestId('recovery-no-backup')).toBeVisible(); @@ -583,20 +609,27 @@ test('of two regenerations, the later is kept on both and A, whose phrase lost, browser, }) => { const { a, b } = await paired(browser); + await switchSettingsPage(a, 'recovery'); + await switchSettingsPage(b, 'recovery'); const lost = await regenerate(a, PASSWORD_A); const kept = await regenerate(b, PASSWORD_B); expect(kept).not.toBe(lost); + await switchSettingsPage(a, 'devices'); + await switchSettingsPage(b, 'devices'); await synced(a, b); + await expect(b.getByTestId('merge-result')).not.toContainText('Recovery Phrase'); const warning = a.getByTestId('phrase-superseded'); await expect(warning).toContainText( `The Recovery Phrase you wrote down for this device, ${lost},`, ); await expect(warning).toContainText('keep it until you have a new backup'); - await expect(b.getByTestId('merge-result')).not.toContainText('Recovery Phrase'); - for (const page of [a, b]) expect(await currentId(page)).toBe(kept); await warning.getByRole('button', { name: 'Make a backup now', exact: true }).click(); await expect(a.getByTestId('backup-password')).toBeFocused(); + for (const page of [a, b]) { + await switchSettingsPage(page, 'recovery'); + expect(await currentId(page)).toBe(kept); + } }); test('a live sync outlives Settings: an item made in the vault reaches the peer at once, until End (D225)', async ({ @@ -614,6 +647,8 @@ test('a live sync outlives Settings: an item made in the vault reaches the peer await expect(a.getByTestId('sync')).toHaveCount(0); await expect(a.getByTestId('live-sync')).toBeVisible(); await button(a, 'Back').click(); + await button(a, 'Back').click(); + await button(b, 'Back').click(); await button(b, 'Back').click(); await button(b, 'Open the vault').click(); await expect(a.getByTestId('live-sync')).toBeVisible(); @@ -637,6 +672,7 @@ test('a lock ends the live sync on both (D93, D225)', async ({ browser }) => { await synced(a, b); await expect(a.getByTestId('live-sync')).toBeVisible({ timeout: 30_000 }); await button(a, 'Back').click(); + await button(a, 'Back').click(); await expect(a.getByTestId('live-sync')).toBeVisible(); await button(b, 'Lock').click(); await expect(a.getByTestId('live-sync')).toHaveCount(0, { timeout: 30_000 }); @@ -648,8 +684,10 @@ test('Settings shows the sync on the row of its peer while it runs, and the last const { a, b } = await paired(browser); // The pairing is the first completed sync (D34). for (const page of [a, b]) { - await expect(page.getByTestId('device-last-sync-any')).toHaveText(/^Last sync: .+, with .+\.$/); await expect(page.getByTestId('device-sync-state')).toHaveCount(0); + await switchSettingsPage(page, 'storage'); + await expect(page.getByTestId('device-last-sync-any')).toHaveText(/^Last sync: .+, with .+\.$/); + await switchSettingsPage(page, 'devices'); } const before = await a.getByTestId('device-last-sync').innerText(); await synced(a, b); @@ -661,9 +699,11 @@ test('Settings shows the sync on the row of its peer while it runs, and the last await expect(page.getByTestId('device-last-sync')).toHaveText( /^Last synced with this device: .+\d{1,2}:\d{2}/, ); + await switchSettingsPage(page, 'storage'); await expect(page.getByTestId('device-last-sync-any')).toHaveText( /^Last sync: .+\d{1,2}:\d{2}.*, with .+\.$/, ); + await switchSettingsPage(page, 'devices'); } expect(before).toMatch(/\d{1,2}:\d{2}/); await a.getByTestId('sync-end').click(); diff --git a/apps/web/e2e/totp.spec.ts b/apps/web/e2e/totp.spec.ts index bbb1fe4..548a78a 100644 --- a/apps/web/e2e/totp.spec.ts +++ b/apps/web/e2e/totp.spec.ts @@ -2,7 +2,7 @@ import { createHmac } from 'node:crypto'; import { type Page, expect, test } from '@playwright/test'; -import { createVault } from './create'; +import { createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9), on every browser. test.setTimeout(120_000); @@ -105,11 +105,12 @@ test('copies a TOTP code and a secret, then clears the clipboard after the timeo page, }) => { await create(page); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'locking'); await page.getByTestId('clipboardClearSeconds').fill('10'); await page.getByRole('button', { name: 'Save', exact: true }).click(); await expect(page.getByText('Saved.', { exact: true })).toBeVisible(); await page.getByRole('button', { name: 'Back', exact: true }).click(); + await page.getByRole('button', { name: 'Back', exact: true }).click(); await page.getByRole('button', { name: 'New item', exact: true }).click(); await page.getByRole('button', { name: 'Login', exact: true }).click(); diff --git a/apps/web/e2e/vault.spec.ts b/apps/web/e2e/vault.spec.ts index 55bf71a..f037478 100644 --- a/apps/web/e2e/vault.spec.ts +++ b/apps/web/e2e/vault.spec.ts @@ -1,7 +1,7 @@ import { type Page, expect, test } from '@playwright/test'; import { formAlert } from './alerts'; -import { confirmPhrase, createVault } from './create'; +import { confirmPhrase, createVault, openSettingsPage } from './create'; // Calibrating Argon2id targets about 1 s per derivation (D9), on every browser. test.setTimeout(90_000); @@ -125,7 +125,7 @@ test('keeps the vault after a reload, locked', async ({ page }) => { test('changes the password only with the current one (D168)', async ({ page }) => { await create(page); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'password'); const change = page.getByRole('button', { name: 'Change password' }); await page.getByTestId('current-password').fill('not the password at all'); await page.getByTestId('new-password').fill(NEXT); @@ -144,7 +144,7 @@ test('changes the password only with the current one (D168)', async ({ page }) = test('refuses a lock preference outside its range (D151)', async ({ page }) => { await create(page); - await page.getByRole('button', { name: 'Settings' }).first().click(); + await openSettingsPage(page, 'locking'); await page.getByTestId('autoLockMinutes').fill('0'); await page.getByRole('button', { name: 'Save' }).click(); await expect(page.getByText('A value is outside its range.')).toBeVisible(); diff --git a/apps/web/src/app/nav.test.ts b/apps/web/src/app/nav.test.ts index f85c382..2f7d7d5 100644 --- a/apps/web/src/app/nav.test.ts +++ b/apps/web/src/app/nav.test.ts @@ -176,3 +176,28 @@ describe('leaving an editor with unsaved changes (D234)', () => { expect(nav.current().name).toBe('vault'); }); }); + +describe('Settings pages (D252)', () => { + it('opens a focus on its page over the index, and back leads to the index', () => { + const nav = new Navigation(); + nav.select({ name: 'item', id: ID }); + nav.openSettings('pair'); + expect(nav.current()).toEqual({ name: 'settings', page: 'devices', focus: 'pair' }); + nav.back(); + expect(nav.current()).toEqual({ name: 'settings' }); + nav.back(); + expect(nav.current().name).toBe('item'); + }); + + it('replaces the Settings screens already open', () => { + const nav = new Navigation(); + nav.open({ name: 'settings' }); + nav.open({ name: 'settings', page: 'appearance' }); + nav.openSettings('backup'); + expect(nav.current()).toEqual({ name: 'settings', page: 'backup', focus: 'backup' }); + nav.back(); + nav.back(); + expect(nav.current().name).toBe('vault'); + expect(nav.deeper()).toBe(false); + }); +}); diff --git a/apps/web/src/app/nav.ts b/apps/web/src/app/nav.ts index aec7b51..e062880 100644 --- a/apps/web/src/app/nav.ts +++ b/apps/web/src/app/nav.ts @@ -15,9 +15,42 @@ export type ListScreen = export type SettingsFocus = 'backup' | 'passkey' | 'recovery' | 'devices' | 'pair' | 'readmit' | 'refound' | 'import'; +/** A page of Settings, opened from its index (D252). */ +export type SettingsPage = + | 'appearance' + | 'language' + | 'locking' + | 'password' + | 'passkey' + | 'recovery' + | 'devices' + | 'backup' + | 'merge' + | 'import' + | 'storage' + | 'refound' + | 'erase' + | 'about'; + +/** The page each focus is on. */ +export const FOCUS_PAGE: Readonly> = { + backup: 'backup', + passkey: 'passkey', + recovery: 'recovery', + devices: 'devices', + pair: 'devices', + readmit: 'devices', + refound: 'refound', + import: 'import', +}; + export type Screen = | ListScreen - | { readonly name: 'settings'; readonly focus?: SettingsFocus } + | { + readonly name: 'settings'; + readonly page?: SettingsPage; + readonly focus?: SettingsFocus; + } | { readonly name: 'item'; readonly id: Uint8Array } | { readonly name: 'edit'; readonly id: Uint8Array } | { readonly name: 'history'; readonly id: Uint8Array } @@ -109,6 +142,20 @@ export class Navigation { this.#set([...this.#stack, screen]); } + /** + * Settings over the screens below it: its index, then the page `focus` is on, so back from the + * page leads to the index (D252). Any Settings screen already open is replaced. + */ + openSettings(focus?: SettingsFocus): void { + const below = this.#stack.filter((s) => s.name !== 'settings'); + const index: Screen = { name: 'settings' }; + this.#set( + focus + ? [...below, index, { name: 'settings', page: FOCUS_PAGE[focus], focus }] + : [...below, index], + ); + } + /** The detail of the current list, replacing any other detail. */ select(screen: DetailScreen): void { this.#set([...this.#listStack(), screen]); diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index f481658..03038c0 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -144,7 +144,7 @@ const en = { leaving: 'Leaving BlindVault to copy or paste counts as hiding it: if it stays hidden for {seconds} seconds, the pairing ends.', leavingImmediate: - 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock below.', + 'This vault locks as soon as BlindVault is hidden, which ends the pairing: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.', answerPaste: 'The other device’s answer', connect: 'Connect', connecting: 'Connecting…', @@ -333,7 +333,7 @@ const en = { leaving: 'Leaving BlindVault to copy or paste counts as hiding it: if it stays hidden for {seconds} seconds, the sync ends.', leavingImmediate: - 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock below.', + 'This vault locks as soon as BlindVault is hidden, which ends the sync: copy and paste without leaving the app, or change the hidden-app lock in Settings › Locking.', paste: { malformed: 'This is not a whole BlindVault sync text. Copy it again, whole.', version: 'This text comes from another version of BlindVault: update both devices.', @@ -504,7 +504,7 @@ const en = { "This backup holds records dated ahead of this device's clock. It can be merged from {date}.", locked: 'The merge stopped before anything was saved. Open the file again.', phraseChanged: - 'The Recovery Phrase was regenerated on another device: the new one, {id}, was created on {date}. New backups open only with it: check yours with Check Recovery Phrase below.', + 'The Recovery Phrase was regenerated on another device: the new one, {id}, was created on {date}. New backups open only with it: check yours in Settings › Recovery Phrase.', phraseSuperseded: 'The Recovery Phrase you wrote down for this device, {id}, created on {date}, was replaced by one regenerated on another device. Backups made with it open only with it, so keep it until you have a new backup. New backups open only with the new phrase.', backupNow: 'Make a backup now', @@ -772,6 +772,33 @@ const en = { }, settings: { title: 'Settings', + groups: { + preferences: 'Preferences', + security: 'Security', + devices: 'Devices', + data: 'Data', + danger: 'Danger zone', + }, + pages: { + appearance: 'Appearance', + language: 'Language', + locking: 'Locking', + password: 'Local password', + passkey: 'Passkey unlock', + recovery: 'Recovery Phrase', + devices: 'Devices', + backup: 'Backup', + merge: 'Merge a backup', + import: 'Import', + storage: 'This device', + refound: 'Re-found the vault', + erase: 'Erase from this device', + about: 'About BlindVault', + }, + hintMinutes: '{n} min', + hintOn: 'On', + hintOff: 'Off', + hintNever: 'Never', appearance: 'Appearance', theme: 'Theme', themes: { system: 'System', light: 'Light', dark: 'Dark' }, @@ -827,7 +854,7 @@ const en = { recovery: 'Recovery Phrase', recoveryCreated: 'The current Recovery Phrase was created on {date} ({id}).', recoveryNoBackup: - 'No backup made on this device opens with the current Recovery Phrase yet: make one in Backup, above.', + 'No backup made on this device opens with the current Recovery Phrase yet: make one in Settings › Backup.', regenTitle: 'Regenerate the Recovery Phrase', regenIntro: 'If you lost the Recovery Phrase, regenerate it while the vault is unlocked: a new phrase replaces it, and nothing in the vault changes.', @@ -1193,7 +1220,7 @@ const ptBR: Messages = { leaving: 'Sair do BlindVault para copiar ou colar conta como ocultá-lo: se ele ficar oculto por {seconds} segundos, o pareamento termina.', leavingImmediate: - 'Este cofre bloqueia assim que o BlindVault fica oculto, o que termina o pareamento: copie e cole sem sair do app, ou mude o bloqueio do app oculto abaixo.', + 'Este cofre bloqueia assim que o BlindVault fica oculto, o que termina o pareamento: copie e cole sem sair do app, ou mude o bloqueio do app oculto em Configurações › Bloqueio.', answerPaste: 'Resposta do outro dispositivo', connect: 'Conectar', connecting: 'Conectando…', @@ -1388,7 +1415,7 @@ const ptBR: Messages = { leaving: 'Sair do BlindVault para copiar ou colar conta como ocultá-lo: se ele ficar oculto por {seconds} segundos, a sincronização termina.', leavingImmediate: - 'Este cofre bloqueia assim que o BlindVault fica oculto, o que encerra a sincronização: copie e cole sem sair do app, ou mude o bloqueio do app oculto abaixo.', + 'Este cofre bloqueia assim que o BlindVault fica oculto, o que encerra a sincronização: copie e cole sem sair do app, ou mude o bloqueio do app oculto em Configurações › Bloqueio.', paste: { malformed: 'Este não é um texto de sincronização do BlindVault inteiro. Copie-o de novo, inteiro.', @@ -1562,7 +1589,7 @@ const ptBR: Messages = { 'Este backup contém registros com data à frente do relógio deste dispositivo. Ele pode ser mesclado a partir de {date}.', locked: 'A mesclagem parou antes de salvar qualquer coisa. Abra o arquivo de novo.', phraseChanged: - 'A Frase de Recuperação foi regenerada em outro dispositivo: a nova, {id}, foi criada em {date}. Os novos backups só abrem com ela: confira a sua em Conferir a Frase de Recuperação, abaixo.', + 'A Frase de Recuperação foi regenerada em outro dispositivo: a nova, {id}, foi criada em {date}. Os novos backups só abrem com ela: confira a sua em Configurações › Frase de Recuperação.', phraseSuperseded: 'A Frase de Recuperação que você anotou para este dispositivo, {id}, criada em {date}, foi substituída por uma regenerada em outro dispositivo. Os backups feitos com ela só abrem com ela, então guarde-a até ter um novo backup. Os novos backups só abrem com a frase nova.', backupNow: 'Fazer um backup agora', @@ -1828,6 +1855,33 @@ const ptBR: Messages = { }, settings: { title: 'Configurações', + groups: { + preferences: 'Preferências', + security: 'Segurança', + devices: 'Dispositivos', + data: 'Dados', + danger: 'Zona de perigo', + }, + pages: { + appearance: 'Aparência', + language: 'Idioma', + locking: 'Bloqueio', + password: 'Senha local', + passkey: 'Desbloqueio com passkey', + recovery: 'Frase de Recuperação', + devices: 'Dispositivos', + backup: 'Backup', + merge: 'Mesclar um backup', + import: 'Importar', + storage: 'Este dispositivo', + refound: 'Refundar o cofre', + erase: 'Apagar deste dispositivo', + about: 'Sobre o BlindVault', + }, + hintMinutes: '{n} min', + hintOn: 'Ativado', + hintOff: 'Desativado', + hintNever: 'Nunca', appearance: 'Aparência', theme: 'Tema', themes: { system: 'Sistema', light: 'Claro', dark: 'Escuro' }, @@ -1883,7 +1937,7 @@ const ptBR: Messages = { recovery: 'Frase de Recuperação', recoveryCreated: 'A Frase de Recuperação atual foi criada em {date} ({id}).', recoveryNoBackup: - 'Nenhum backup feito neste dispositivo abre com a Frase de Recuperação atual ainda: faça um em Backup, acima.', + 'Nenhum backup feito neste dispositivo abre com a Frase de Recuperação atual ainda: faça um em Configurações › Backup.', regenTitle: 'Regenerar a Frase de Recuperação', regenIntro: 'Se você perdeu a Frase de Recuperação, regenere-a com o cofre desbloqueado: uma frase nova substitui a atual, e nada no cofre muda.', diff --git a/apps/web/src/ui/FirstSteps.tsx b/apps/web/src/ui/FirstSteps.tsx index 496a4e7..38d05c2 100644 --- a/apps/web/src/ui/FirstSteps.tsx +++ b/apps/web/src/ui/FirstSteps.tsx @@ -21,7 +21,7 @@ export function FirstSteps({ const m = useMessages(); const id = useId(); const settings = (focus: SettingsFocus) => () => { - nav.open({ name: 'settings', focus }); + nav.openSettings(focus); }; const steps = [ ...(session.writable diff --git a/apps/web/src/ui/ImportItems.tsx b/apps/web/src/ui/ImportItems.tsx index fd87cb6..45bb187 100644 --- a/apps/web/src/ui/ImportItems.tsx +++ b/apps/web/src/ui/ImportItems.tsx @@ -85,9 +85,6 @@ export function ImportItems({ return (
-

- {m.import.title} -

{m.import.intro}

{!session.writable ? ( {m.import.readOnly} diff --git a/apps/web/src/ui/MergeBackup.tsx b/apps/web/src/ui/MergeBackup.tsx index ebc748f..4192690 100644 --- a/apps/web/src/ui/MergeBackup.tsx +++ b/apps/web/src/ui/MergeBackup.tsx @@ -6,7 +6,7 @@ import { type BackupHeaderInfo, type MergeOutcome, type VaultController } from ' import { useLanguage } from '../i18n/context'; import { type Messages, format } from '../i18n/messages'; import { Button, Notice } from './components'; -import { focusBackup } from './RegeneratePhrase'; +import { useOpenBackup } from './RegeneratePhrase'; import { ChooseFile, OpenBackup, fileError } from './RestoreVault'; import { recordTypeName } from './Settings'; @@ -47,7 +47,6 @@ export function MergeBackup({ }).format(at); return (
-

{m.merge.title}

{m.merge.intro}

{step.kind === 'idle' || step.kind === 'done' ? ( diff --git a/apps/web/src/ui/Ready.tsx b/apps/web/src/ui/Ready.tsx index 5ed6ce1..fd2471b 100644 --- a/apps/web/src/ui/Ready.tsx +++ b/apps/web/src/ui/Ready.tsx @@ -87,7 +87,7 @@ export function Ready({ diff --git a/apps/web/src/ui/Settings.tsx b/apps/web/src/ui/Settings.tsx index dc2e753..938a504 100644 --- a/apps/web/src/ui/Settings.tsx +++ b/apps/web/src/ui/Settings.tsx @@ -5,7 +5,14 @@ import { type SyncPurpose, type VaultSession, } from '@blindvault/vault'; -import { useCallback, useEffect, useState, useSyncExternalStore } from 'react'; +import { + type ReactNode, + useCallback, + useEffect, + useId, + useState, + useSyncExternalStore, +} from 'react'; import { ACCENTS, @@ -17,7 +24,7 @@ import { import { download, phraseLabel, recordIdPrefix } from '../app/backup-file'; import { devicesOf, eraseAdvice, peerSyncState, rejectedPeersOf } from '../app/devices'; import { folderNameOf } from '../app/folders'; -import { type Navigation, type SettingsFocus } from '../app/nav'; +import { type Navigation, type SettingsFocus, type SettingsPage } from '../app/nav'; import { type BackupExport, type ExportAuth, @@ -26,7 +33,7 @@ import { type VaultController, } from '../app/controller'; import { useLanguage } from '../i18n/context'; -import { LANGUAGES, type Messages, format } from '../i18n/messages'; +import { LANGUAGES, type Language, type Messages, format } from '../i18n/messages'; import { AboutBuild } from './About'; import { Button, ConfirmDialog, Field, INPUT, Notice, TARGET, TextArea } from './components'; import { EraseVault } from './EraseVault'; @@ -37,57 +44,184 @@ import { NewPassword } from './NewPassword'; import { InviteDevice, ReadmitDevice } from './Pairing'; import { SyncDevice } from './Sync'; import { RefoundVault } from './RefoundVault'; -import { RegeneratePhrase, focusBackup } from './RegeneratePhrase'; +import { OpenBackup, RegeneratePhrase, focusBackup, useOpenBackup } from './RegeneratePhrase'; +import { Icon, type IconName } from './icons'; import { DeviceStorage } from './StorageState'; +/** The index's groups, in order; the last has no title (D252). */ +const GROUPS: readonly (readonly [ + keyof Messages['settings']['groups'] | undefined, + readonly SettingsPage[], +])[] = [ + ['preferences', ['appearance', 'language', 'locking']], + ['security', ['password', 'passkey', 'recovery']], + ['devices', ['devices']], + ['data', ['backup', 'merge', 'import', 'storage']], + ['danger', ['refound', 'erase']], + [undefined, ['about']], +]; + +const PAGE_ICON: Readonly> = { + appearance: 'palette', + language: 'languages', + locking: 'timer', + password: 'key-round', + passkey: 'fingerprint', + recovery: 'shield-check', + devices: 'monitor-smartphone', + backup: 'upload', + merge: 'git-merge', + import: 'download', + storage: 'hard-drive', + refound: 'refresh-cw', + erase: 'trash-2', + about: 'info', +}; + +const LANGUAGE_NAME: Readonly> = { + en: 'English', + 'pt-BR': 'Português (Brasil)', +}; + +/** + * Settings (D252): an index of pages in groups, each row with its current value where one helps; + * a row opens its page over the index, and back returns to it. + */ export function Settings({ controller, session, nav, + page, focus, }: { readonly controller: VaultController; readonly session: VaultSession; readonly nav: Navigation; + readonly page?: SettingsPage | undefined; /** * Opened from the backup reminder (D188), "Vault ready" (D198) or the clock notice (D221): where - * the cursor goes. + * the cursor goes on `page`. */ readonly focus?: SettingsFocus | undefined; +}) { + const openBackup = useCallback(() => { + nav.openSettings('backup'); + }, [nav]); + return ( + + {page ? ( + + ) : ( + + )} + + ); +} + +function SettingsIndex({ + session, + nav, +}: { + readonly session: VaultSession; + readonly nav: Navigation; +}) { + const { messages: m, language } = useLanguage(); + const s = m.settings; + const last = session.lastBackupAt; + const hint: Partial> = { + appearance: s.themes[currentAppearance().theme], + language: LANGUAGE_NAME[language], + locking: format(s.hintMinutes, { n: String(session.preferences.autoLockMinutes) }), + passkey: session.passkeyCredentialId === undefined ? s.hintOff : s.hintOn, + backup: + last === undefined + ? s.hintNever + : new Intl.DateTimeFormat(language, { dateStyle: 'medium' }).format(last), + devices: String(devicesOf(session).listed.length), + }; + return ( +
+

{s.title}

+ {GROUPS.map(([group, pages]) => ( + + {pages.map((page) => ( +
  • + +
  • + ))} +
    + ))} +
    + ); +} + +function SettingsGroup({ + title, + children, +}: { + readonly title: string | undefined; + readonly children: ReactNode; +}) { + const id = useId(); + return ( +
    + {title ? ( +

    + {title} +

    + ) : null} +
      + {children} +
    +
    + ); +} + +/** One page of Settings, under its heading. Opened without a focus, the heading takes the cursor. */ +function SettingsPageView({ + controller, + session, + nav, + page, + focus, +}: { + readonly controller: VaultController; + readonly session: VaultSession; + readonly nav: Navigation; + readonly page: SettingsPage; + readonly focus: SettingsFocus | undefined; }) { const { messages: m, language, setLanguage } = useLanguage(); useEffect(() => { if (focus === 'backup') focusBackup(); - // The passkey section shows once support is known: it focuses itself. - else if ( - focus === 'recovery' || - focus === 'devices' || - focus === 'refound' || - focus === 'import' - ) - focusSection(focus); + // The passkey page shows once support is known: it focuses itself. else if (focus === 'pair') focusField('invite-password'); else if (focus === 'readmit') focusField('readmission-first'); + else if (focus === 'recovery' || focus === 'refound' || focus === 'import') focusPage(); + else if (focus !== 'passkey') focusHeading(); }, [focus]); - return ( -
    -

    {m.settings.title}

    - - - - - - - - - - - - + const content: Record ReactNode> = { + appearance: () => , + language: () => ( -
    -

    {m.about.title}

    - -
    + ), + locking: () => , + password: () => , + passkey: () => ( + + ), + recovery: () => , + devices: () => ( + + ), + backup: () => , + merge: () => , + import: () => , + storage: () => , + refound: () => , + erase: () => , + about: () => , + }; + return ( +
    +

    + {m.settings.pages[page]} +

    + {content[page]()}
    ); } @@ -120,7 +281,6 @@ function AppearanceSettings() { }; return (
    -

    {m.settings.appearance}

    {m.settings.theme} {THEMES.map((theme) => ( @@ -193,7 +353,6 @@ function ChangePassword({ controller }: { readonly controller: VaultController } }); }} > -

    {m.settings.password}

    -

    {m.settings.locking}

    {field('autoLockMinutes', m.settings.autoLock, 1, 60)} {field('hiddenLockSeconds', m.settings.hiddenLock, 0, 900)} {values.hiddenLockSeconds === '0' ? ( @@ -309,16 +467,16 @@ function EraseSettings({ readonly session: VaultSession; }) { const m = useLanguage().messages; + const openBackup = useOpenBackup(); const [open, setOpen] = useState(false); return (
    -

    {m.erase.title}

    {m.erase.intro}

    {open ? ( { setOpen(false); }} @@ -338,11 +496,15 @@ function EraseSettings({ ); } -/** Scrolls to a section and puts the cursor in its first field, or on its heading (D198). */ -function focusSection(name: SettingsFocus): void { - const section = document.querySelector(`[data-section="${name}"]`); - const target = - section?.querySelector('input, textarea') ?? section?.querySelector('h2'); +/** The open page's heading takes the cursor, as a new screen (D252). */ +function focusHeading(): void { + document.querySelector('[data-settings-page] h1')?.focus(); +} + +/** Puts the cursor in the open page's first field, or on its heading (D198, D252). */ +function focusPage(): void { + const page = document.querySelector('[data-settings-page]'); + const target = page?.querySelector('input, textarea') ?? page?.querySelector('h1'); target?.scrollIntoView({ block: 'center' }); target?.focus(); } @@ -351,7 +513,7 @@ function focusSection(name: SettingsFocus): void { function focusField(testId: string): void { const target = document.querySelector(`[data-testid="${testId}"]`); if (!target) { - focusSection('devices'); + focusPage(); return; } target.scrollIntoView({ block: 'center' }); @@ -381,7 +543,7 @@ function PasskeySettings({ }; }, [controller]); useEffect(() => { - if (focus && supported !== undefined) focusSection('passkey'); + if (focus && supported !== undefined) focusPage(); }, [focus, supported]); if (supported === undefined) return null; const on = session.passkeyCredentialId !== undefined; @@ -393,9 +555,6 @@ function PasskeySettings({ }; return (
    -

    - {m.settings.passkey} -

    {!supported && !on ? (

    {m.settings.passkeyUnsupported}

    ) : null} @@ -519,9 +678,6 @@ function RecoveryPhraseSettings({ const last = session.lastBackupAt; return (
    -

    - {m.settings.recovery} -

    {envelope === 'none' ? {m.settings.recoveryNone} : null} {envelope === 'corrupt' ? {m.settings.recoveryCorrupt} : null} {envelope === 'held' && recovery ? ( @@ -693,9 +849,6 @@ function DeviceSettings({ const state = peerSyncState(stage); return (
    -

    - {m.settings.devices} -

    {devices.advice === 'readmit' ? ( {m.settings.devicesReadmit} @@ -1006,12 +1159,7 @@ function BackupSettings({ setExportPassword(p); }, []); if (!session.recovery) { - return ( -
    -

    {m.settings.backup}

    - {m.settings.backupNone} -
    - ); + return {m.settings.backupNone}; } const last = session.lastBackupAt; const { phraseId, phraseCreatedAt } = session.recovery; @@ -1043,7 +1191,6 @@ function BackupSettings({ run({ kind: 'password', password }); }} > -

    {m.settings.backup}

    {m.settings.backupIntro}

    {last === undefined diff --git a/apps/web/src/ui/StorageState.tsx b/apps/web/src/ui/StorageState.tsx index 62ac42a..8e4ec67 100644 --- a/apps/web/src/ui/StorageState.tsx +++ b/apps/web/src/ui/StorageState.tsx @@ -121,7 +121,6 @@ export function DeviceStorage({ const quota = status?.quota ?? null; return (

    -

    {m.storage.title}

    {persisted === true ? m.storage.kept diff --git a/apps/web/src/ui/VaultShell.tsx b/apps/web/src/ui/VaultShell.tsx index 7b75c8d..d0aa98c 100644 --- a/apps/web/src/ui/VaultShell.tsx +++ b/apps/web/src/ui/VaultShell.tsx @@ -25,7 +25,6 @@ import { ItemHistory } from './ItemHistory'; import { FolderPane } from './Folders'; import { ItemList } from './ItemList'; import { Ready } from './Ready'; -import { focusBackup } from './RegeneratePhrase'; import { Settings } from './Settings'; import { StorageWarningBanner } from './StorageState'; import { Icon } from './icons'; @@ -186,7 +185,7 @@ function LiveSync({

    {settings ? ( - + ) : null} ); @@ -430,7 +435,7 @@ export function VaultShell({ variant="quiet" data-testid="clock-refound" onClick={() => { - nav.open({ name: 'settings', focus: 'refound' }); + nav.openSettings('refound'); }} > {m.refound.title} @@ -492,8 +497,7 @@ export function VaultShell({