From d4775fb7fe1ea4403822c3be6db725d5f42d4d4c Mon Sep 17 00:00:00 2001
From: Allisson Azevedo
Date: Thu, 12 Nov 2026 14:00:08 -0300
Subject: [PATCH] feat(web): Settings pages, an index in groups (D252)
Settings was one long page of every section. It is now an index in
groups (Preferences, Security, Devices, Data, Danger zone, About), each
row with its icon, a chevron and, for six rows, the current value; a row
opens its page over the index, and Back returns to it. Shortcuts that
opened Settings at a section open that section's page.
Co-Authored-By: Claude Opus 5.5
---
apps/web/e2e/about.spec.ts | 4 +-
apps/web/e2e/accessibility.spec.ts | 10 +
apps/web/e2e/appearance.spec.ts | 4 +-
apps/web/e2e/axe-devices.spec.ts | 3 +-
apps/web/e2e/axe.spec.ts | 9 +
apps/web/e2e/backup-merge.spec.ts | 22 +-
apps/web/e2e/backup.spec.ts | 6 +-
apps/web/e2e/clock-behind.spec.ts | 7 +-
apps/web/e2e/create.ts | 29 +++
apps/web/e2e/device-rename.spec.ts | 6 +-
apps/web/e2e/erase.spec.ts | 9 +-
apps/web/e2e/feedback.spec.ts | 3 +-
apps/web/e2e/form-history.spec.ts | 11 +-
apps/web/e2e/import.spec.ts | 5 +-
apps/web/e2e/kept-aside.spec.ts | 8 +-
apps/web/e2e/late-code.spec.ts | 6 +-
apps/web/e2e/layout.spec.ts | 20 +-
apps/web/e2e/list-row.spec.ts | 5 +-
apps/web/e2e/logs.spec.ts | 4 +-
apps/web/e2e/navigation.spec.ts | 49 ++++
apps/web/e2e/onboarding.spec.ts | 3 +
apps/web/e2e/pairing-qr.spec.ts | 4 +-
apps/web/e2e/pairing-stun.spec.ts | 6 +-
apps/web/e2e/pairing.spec.ts | 29 ++-
apps/web/e2e/passkey.spec.ts | 12 +-
apps/web/e2e/phrase-regeneration.spec.ts | 17 +-
apps/web/e2e/readmission.spec.ts | 12 +-
apps/web/e2e/recovery-phrase.spec.ts | 4 +-
apps/web/e2e/refounding.spec.ts | 23 +-
apps/web/e2e/replace-vault.spec.ts | 5 +-
apps/web/e2e/restore.spec.ts | 5 +-
apps/web/e2e/restored-device.spec.ts | 9 +-
apps/web/e2e/storage-state.spec.ts | 4 +-
apps/web/e2e/sync.spec.ts | 78 +++++--
apps/web/e2e/totp.spec.ts | 5 +-
apps/web/e2e/vault.spec.ts | 6 +-
apps/web/src/app/nav.test.ts | 25 ++
apps/web/src/app/nav.ts | 49 +++-
apps/web/src/i18n/messages.ts | 70 +++++-
apps/web/src/ui/FirstSteps.tsx | 2 +-
apps/web/src/ui/ImportItems.tsx | 3 -
apps/web/src/ui/MergeBackup.tsx | 6 +-
apps/web/src/ui/Ready.tsx | 2 +-
apps/web/src/ui/RefoundVault.tsx | 3 -
apps/web/src/ui/RegeneratePhrase.tsx | 12 +-
apps/web/src/ui/Settings.tsx | 279 +++++++++++++++++------
apps/web/src/ui/StorageState.tsx | 1 -
apps/web/src/ui/VaultShell.tsx | 16 +-
apps/web/src/ui/icons/index.tsx | 98 ++++++++
docs/overview.md | 1 +
docs/roadmap.md | 6 +-
51 files changed, 811 insertions(+), 204 deletions(-)
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' ? (
{m.settings.regenOtherDevices}
{m.settings.regenOldBackups}
-
+
{m.settings.regenBackupNow}
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 (
+
{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({
{
- if (screen.name === 'settings') focusBackup();
- else nav.open({ name: 'settings', focus: 'backup' });
+ nav.openSettings('backup');
}}
>
{m.vault.reminderBackup}
diff --git a/apps/web/src/ui/icons/index.tsx b/apps/web/src/ui/icons/index.tsx
index 3d0a669..7c7ccbd 100644
--- a/apps/web/src/ui/icons/index.tsx
+++ b/apps/web/src/ui/icons/index.tsx
@@ -8,6 +8,7 @@ import type { ReactNode } from 'react';
const PATHS = {
check: ,
'chevron-left': ,
+ 'chevron-right': ,
'code-xml': (
<>
@@ -36,6 +37,13 @@ const PATHS = {
>
),
+ download: (
+ <>
+
+
+
+ >
+ ),
ellipsis: (
<>
@@ -66,9 +74,29 @@ const PATHS = {
>
),
+ fingerprint: (
+ <>
+
+
+
+
+
+
+
+
+
+ >
+ ),
folder: (
),
+ 'git-merge': (
+ <>
+
+
+
+ >
+ ),
globe: (
<>
@@ -76,6 +104,14 @@ const PATHS = {
>
),
+ 'hard-drive': (
+ <>
+
+
+
+
+ >
+ ),
history: (
<>
@@ -92,12 +128,29 @@ const PATHS = {
>
),
+ info: (
+ <>
+
+
+
+ >
+ ),
'key-round': (
<>
>
),
+ languages: (
+ <>
+
+
+
+
+
+
+ >
+ ),
list: (
<>
@@ -115,6 +168,23 @@ const PATHS = {
>
),
+ 'monitor-smartphone': (
+ <>
+
+
+
+
+ >
+ ),
+ palette: (
+ <>
+
+
+
+
+
+ >
+ ),
pencil: (
<>
@@ -127,12 +197,26 @@ const PATHS = {
>
),
+ 'refresh-cw': (
+ <>
+
+
+
+
+ >
+ ),
settings: (
<>
>
),
+ 'shield-check': (
+ <>
+
+
+ >
+ ),
'sticky-note': (
<>
@@ -145,6 +229,13 @@ const PATHS = {
>
),
+ timer: (
+ <>
+
+
+
+ >
+ ),
'trash-2': (
<>
@@ -154,6 +245,13 @@ const PATHS = {
>
),
+ upload: (
+ <>
+
+
+
+ >
+ ),
wallet: (
<>
diff --git a/docs/overview.md b/docs/overview.md
index aa84dcc..b97c126 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -1012,3 +1012,4 @@ This document is **normative**.
| D249 | Item password strength | The fifth area of Phase 7b (D244). **What is rated.** A template's `password` field of type `secret`, the one secret whose use is known; a PIN, a card's security code, a TOTP secret, a seed phrase and the user's own secret fields are not rated, since a score for them would mislead. **How.** zxcvbn with the dictionaries of D71, already loaded before the first unlock (D191), run in the page on the value in memory; nothing is stored or sent. Scores 0 to 2 are Weak, 3 Fair, 4 Strong. **Editor.** Under the password, a meter of four bars, seen only (`aria-hidden`), and the field's hint, its accessible description: "Strength: .", then zxcvbn's warning or a fixed advice below Strong. **Detail.** A Weak badge beside the password's name, only when weak; nothing for Fair or Strong. **Never blocks.** Unlike the local and export passwords (D71, D134), a weak item password is saved like any other: the site sets its rules, and the user may hold a password they cannot change. **Exposure.** The badge says a password is weak to someone who sees the screen, as the masked value does not; the detail is already shown only unlocked (section 13.2), and the list row shows no rating (CRYPTO-INV-024). |
| D250 | Feedback | The sixth area of Phase 7b (D244). **Toast.** One region of the app's own, a `role=status` mounted with the vault shell, so each toast is announced and none outlives a lock; at the bottom, above the phone's navigation, never taking the pointer. A new toast replaces the last, and each goes after 5 s. It says that an item was saved, moved to the trash or restored, and what a copy said beside its button before: copied, with the clearing that is not guaranteed (D54, D127), or that the browser refused; the check of D231 stays on the button. A toast holds no secret. Its entrance is motion under D236, none under `prefers-reduced-motion`. **Bottom sheet.** On a phone the confirmation of D231 is a bottom sheet: the same native modal `