From fa3d9a4a9f25c8304b35b10f1c4e6beeeaf7b8fa Mon Sep 17 00:00:00 2001 From: Allisson Azevedo Date: Thu, 12 Nov 2026 10:14:51 -0300 Subject: [PATCH] feat(web): feedback, the sixth area of Phase 7b (D250) Toast region, bottom sheet on a phone, the empty vault's first steps, and an error boundary that locks the vault before its failure screen. Co-Authored-By: Claude Opus 5.5 --- apps/web/e2e/backup.spec.ts | 2 +- apps/web/e2e/feedback.spec.ts | 162 ++++++++++++++ apps/web/e2e/folders.spec.ts | 2 +- apps/web/e2e/items.spec.ts | 2 +- apps/web/e2e/recovery-phrase.spec.ts | 2 +- apps/web/e2e/screen-reader.spec.ts | 29 ++- apps/web/e2e/sync.spec.ts | 4 +- apps/web/src/App.tsx | 5 +- apps/web/src/app/nav.ts | 2 +- apps/web/src/i18n/messages.ts | 30 ++- apps/web/src/ui/ErrorBoundary.tsx | 56 +++++ apps/web/src/ui/FirstSteps.tsx | 79 +++++++ apps/web/src/ui/ImportItems.tsx | 6 +- apps/web/src/ui/ItemDetail.tsx | 9 +- apps/web/src/ui/ItemEditor.tsx | 3 + apps/web/src/ui/ItemList.tsx | 13 +- apps/web/src/ui/Secret.tsx | 22 +- apps/web/src/ui/Settings.tsx | 7 +- apps/web/src/ui/Toast.tsx | 60 +++++ apps/web/src/ui/Totp.tsx | 3 +- apps/web/src/ui/VaultShell.tsx | 320 ++++++++++++++------------- apps/web/src/ui/components.tsx | 39 +++- docs/overview.md | 1 + docs/roadmap.md | 2 +- docs/security-invariants.md | 2 +- 25 files changed, 652 insertions(+), 210 deletions(-) create mode 100644 apps/web/e2e/feedback.spec.ts create mode 100644 apps/web/src/ui/ErrorBoundary.tsx create mode 100644 apps/web/src/ui/FirstSteps.tsx create mode 100644 apps/web/src/ui/Toast.tsx diff --git a/apps/web/e2e/backup.spec.ts b/apps/web/e2e/backup.spec.ts index a516539..8ae589f 100644 --- a/apps/web/e2e/backup.spec.ts +++ b/apps/web/e2e/backup.spec.ts @@ -76,7 +76,7 @@ test('exports a backup, asking for the local password again, then one under a ge expect(plain.bytes.includes(SECRET)).toBe(false); expect(plain.bytes.includes('ACME')).toBe(false); // The status names the phrase the file opens with, by its phraseId and date (D185). - await expect(page.getByRole('status')).toHaveText( + await expect(page.getByRole('main').getByRole('status')).toHaveText( new RegExp( `^Backup exported as ${plain.name.replaceAll('.', '\\.')}\\. It opens with the Recovery Phrase [0-9a-f]{8}, created on .+\\. Keep it somewhere other than this device\\.$`, ), diff --git a/apps/web/e2e/feedback.spec.ts b/apps/web/e2e/feedback.spec.ts new file mode 100644 index 0000000..2e6d103 --- /dev/null +++ b/apps/web/e2e/feedback.spec.ts @@ -0,0 +1,162 @@ +import { type Page, expect, test } from '@playwright/test'; + +import { createVault } from './create'; + +// Feedback (D250): the toast region, the confirmation as a bottom sheet on a phone, the empty +// vault's first steps, and the error boundary that locks the vault before its failure screen. + +test.setTimeout(120_000); + +const PASSWORD = 'quartz-lantern-velvet-harbor-9214'; +const SECRET = 'Tulip-Orbit-5528-Granite'; + +const button = (page: Page, name: string) => page.getByRole('button', { name, exact: true }); + +function writes(page: Page): Promise { + return page.evaluate(() => (window as unknown as { clipboardWrites: string[] }).clipboardWrites); +} + +test.beforeEach(async ({ page }) => { + // Records what the app writes to the clipboard, the same way in every browser. + await page.addInitScript(() => { + const log: string[] = []; + Object.defineProperty(window, 'clipboardWrites', { value: log }); + Object.defineProperty(navigator.clipboard, 'writeText', { + value: (text: string) => { + log.push(text); + return Promise.resolve(); + }, + }); + }); +}); + +async function login(page: Page, title: string) { + await page.getByTestId('item-title').fill(title); + await page.getByTestId('input-Password').fill(SECRET); + await button(page, 'Save').click(); + await expect(page.getByRole('heading', { name: title, level: 1 })).toBeVisible(); +} + +test('the empty vault shows its first steps, each opening where it is done (D250)', async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/'); + await createVault(page, PASSWORD); + + const steps = page.getByTestId('first-steps'); + await expect(steps.getByRole('heading', { name: 'First steps' })).toBeVisible(); + await expect(steps.getByRole('listitem')).toHaveCount(4); + 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 button(page, 'Back').click(); + + await steps.getByRole('button', { name: 'Add an item', exact: true }).click(); + await button(page, 'Login').click(); + await login(page, 'ACME'); + await expect(steps).toHaveCount(0); +}); + +test('copy, save, move to trash and restore show a toast, still under reduced motion (D250)', async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.emulateMedia({ reducedMotion: 'reduce' }); + await page.goto('/'); + await createVault(page, PASSWORD); + const region = page.getByTestId('toast-region'); + await expect(region).toHaveAttribute('role', 'status'); + + await button(page, 'New item').click(); + await button(page, 'Login').click(); + await login(page, 'ACME'); + await expect(region).toHaveText('Saved.'); + // No entrance under reduced motion (D236). + expect(await page.getByTestId('toast').evaluate((t) => getComputedStyle(t).animationName)).toBe( + 'none', + ); + + await button(page, 'Copy: Password').click(); + await expect(region).toContainText(/^Copied\. .* not guaranteed\.$/); + expect(await writes(page)).toEqual([SECRET]); + + await button(page, 'More actions').click(); + await button(page, 'Move to trash').click(); + await expect(region).toHaveText('Moved to the trash.'); + + await button(page, 'Trash').click(); + await page.getByRole('button', { name: /^ACME/ }).click(); + await button(page, 'Restore from the trash').click(); + await expect(region).toHaveText('Restored.'); + // It goes on its own. + await expect(page.getByTestId('toast')).toHaveCount(0, { timeout: 10_000 }); +}); + +test('a confirmation is a bottom sheet on a phone and a centered dialog wider (D250)', async ({ + page, +}) => { + await page.setViewportSize({ width: 390, height: 844 }); + await page.goto('/'); + await createVault(page, PASSWORD); + await page.getByTestId('first-steps').getByRole('button', { name: 'Add an item' }).click(); + await button(page, 'Login').click(); + await page.getByTestId('item-title').fill('Unsaved'); + await button(page, 'Cancel').click(); + + const sheet = page.getByTestId('discard-confirm'); + await expect(sheet).toBeVisible(); + await expect(sheet).toHaveAttribute('data-sheet', 'true'); + // Measured once its entrance ends. + await sheet.evaluate((d) => Promise.all(d.getAnimations().map((a) => a.finished))); + const box = await sheet.boundingBox(); + expect(box?.width).toBe(390); + expect(Math.round((box?.y ?? 0) + (box?.height ?? 0))).toBe(844); + // The action first, both full width and at least 44 px tall. + const buttons = sheet.getByRole('button'); + await expect(buttons.first()).toHaveText('Discard the changes'); + for (const b of await buttons.all()) { + const at = await b.boundingBox(); + expect(at?.height).toBeGreaterThanOrEqual(44); + } + await page.keyboard.press('Escape'); + await expect(sheet).toHaveCount(0); + + await page.setViewportSize({ width: 1280, height: 900 }); + await button(page, 'Cancel').click(); + await expect(sheet).toBeVisible(); + await expect(sheet).not.toHaveAttribute('data-sheet'); + await sheet.evaluate((d) => Promise.all(d.getAnimations().map((a) => a.finished))); + expect((await sheet.boundingBox())?.width).toBeLessThan(500); +}); + +test('a render error locks the vault and clears the clipboard before its failure screen (D250)', async ({ + page, +}) => { + await page.setViewportSize({ width: 1280, height: 900 }); + await page.goto('/'); + await createVault(page, PASSWORD); + await button(page, 'New item').click(); + await button(page, 'Login').click(); + await login(page, 'ACME'); + await button(page, 'Copy: Password').click(); + await expect.poll(() => writes(page)).toEqual([SECRET]); + + // The list sorts with Intl.Collator on every render: breaking it makes the next render throw. + await page.evaluate(() => { + Intl.Collator = function () { + throw new Error('render failure for the test'); + } as unknown as typeof Intl.Collator; + }); + await page.getByTestId('search').fill('A'); + + await expect(page.getByTestId('crash')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'Something went wrong' })).toBeVisible(); + await expect(page.getByText('ACME')).toHaveCount(0); + await expect(page.getByTestId('lock-state')).toHaveCount(0); + await expect.poll(() => writes(page)).toEqual([SECRET, '']); + + await button(page, 'Reload').click(); + await expect(page.getByTestId('unlock-password')).toBeVisible(); +}); diff --git a/apps/web/e2e/folders.spec.ts b/apps/web/e2e/folders.spec.ts index 269496b..8f61fa5 100644 --- a/apps/web/e2e/folders.spec.ts +++ b/apps/web/e2e/folders.spec.ts @@ -107,7 +107,7 @@ for (const [layout, width] of WIDTHS) { await folders(page, layout); await expect(button(page, 'Clients')).toHaveCount(0); await button(page, 'All items').click(); - await expect(page.getByText('No items yet.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible(); await folders(page, layout); await button(page, 'Trash').click(); await expect(page.getByText('Folder: Clients')).toBeVisible(); diff --git a/apps/web/e2e/items.spec.ts b/apps/web/e2e/items.spec.ts index 7547fa2..0d721b6 100644 --- a/apps/web/e2e/items.spec.ts +++ b/apps/web/e2e/items.spec.ts @@ -79,7 +79,7 @@ for (const [layout, width] of WIDTHS) { await page.getByRole('button', { name: 'More actions', exact: true }).click(); await page.getByRole('button', { name: 'Move to trash', exact: true }).click(); await expect(page.getByRole('heading', { name: TITLE })).toHaveCount(0); - await expect(page.getByText('No items yet.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible(); await showList(page, layout, 'Trash'); await page.getByRole('button', { name: TITLE, exact: true }).click(); diff --git a/apps/web/e2e/recovery-phrase.spec.ts b/apps/web/e2e/recovery-phrase.spec.ts index 8db72c2..ffde93b 100644 --- a/apps/web/e2e/recovery-phrase.spec.ts +++ b/apps/web/e2e/recovery-phrase.spec.ts @@ -143,7 +143,7 @@ test('checks the Recovery Phrase while unlocked, keeping the typed words nowhere // As written on paper: one word per line, some in capitals. await check(words.map((w, i) => (i % 2 ? w.toUpperCase() : w)).join('\n')); - await expect(page.getByRole('status')).toHaveText( + await expect(page.getByRole('main').getByRole('status')).toHaveText( 'This phrase opens this vault and its backups.', ); diff --git a/apps/web/e2e/screen-reader.spec.ts b/apps/web/e2e/screen-reader.spec.ts index 9e027e1..315c9b7 100644 --- a/apps/web/e2e/screen-reader.spec.ts +++ b/apps/web/e2e/screen-reader.spec.ts @@ -100,7 +100,22 @@ for (const [layout, width] of WIDTHS) { - region "Items": - heading "Items" [level=2] - searchbox "Search" - - paragraph: No items yet. + - region "First steps": + - heading "First steps" [level=3] + - paragraph: "Your vault lives only on your devices. To start:" + - list: + - listitem: + - img "Done" + - text: Create the vault and keep its Recovery Phrase + - listitem: + - text: "Add your first item: a login, a card, a secure note…" + - button "Add an item" + - listitem: + - text: Import from another password manager. + - button "Import" + - listitem: + - text: Pair another device, so the vault has a second copy you can sync with. + - button "Pair a device" `); // A refused save: the field is invalid and the reason is an alert, at the field and the button. @@ -144,18 +159,24 @@ for (const [layout, width] of WIDTHS) { `); await button(page, 'Copy: Password').click(); await expect(page.locator('body')).toMatchAriaSnapshot(` - - status: /clear the clipboard in \\d+ s .* not guaranteed/ + - status: + - paragraph: /clear the clipboard in \\d+ s .* not guaranteed/ `); // The discard confirmation is a dialog named by its action. await button(page, 'Edit').click(); await page.getByTestId('item-title').fill('Harbor Bank'); await button(page, 'Cancel').click(); + // On a phone, a bottom sheet with the action first (D250). + const buttons = + layout === 'mobile' + ? ['Discard the changes', 'Keep editing'] + : ['Keep editing', 'Discard the changes']; await expect(page.getByRole('dialog')).toMatchAriaSnapshot(` - dialog "Discard the changes": - note: The changes made to this item are not saved and will be lost. - - button "Keep editing" - - button "Discard the changes" + - button "${buttons[0] ?? ''}" + - button "${buttons[1] ?? ''}" `); await button(page, 'Discard the changes').click(); diff --git a/apps/web/e2e/sync.spec.ts b/apps/web/e2e/sync.spec.ts index fba777b..b48f4c7 100644 --- a/apps/web/e2e/sync.spec.ts +++ b/apps/web/e2e/sync.spec.ts @@ -488,7 +488,7 @@ test('an edit older than a trash never brings the item back: in the trash on bot await closed(a, b); for (const page of [a, b]) { - await expect(page.getByText('No items yet.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible(); await button(page, 'Trash').click(); await button(page, 'ACME').click(); // An item in the trash offers only restore (D143): restored, its history shows A's edit. @@ -524,7 +524,7 @@ test('an item trashed on A is in the trash of both with the same purge date, off await closed(a, b); const dates: string[] = []; for (const page of [a, b]) { - await expect(page.getByText('No items yet.')).toBeVisible(); + await expect(page.getByRole('heading', { name: 'First steps' })).toBeVisible(); await button(page, 'Trash').click(); await button(page, 'ACME').click(); const notice = page.getByText(/It can only be restored from the trash; it is purged on /); diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index ad9d2eb..ba6c316 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -10,6 +10,7 @@ import { type UpdateMonitor } from './app/updates'; import { LanguageProvider } from './i18n/context'; import { AboutContext } from './ui/about-context'; import { ChangedWorker } from './ui/ChangedWorker'; +import { ErrorBoundary } from './ui/ErrorBoundary'; import { Start } from './ui/Start'; import { UpdatesContext } from './ui/update-context'; import { VaultShell } from './ui/VaultShell'; @@ -132,7 +133,9 @@ export function App({ controller, monitor }: RootProps) { - + + + diff --git a/apps/web/src/app/nav.ts b/apps/web/src/app/nav.ts index 1523d18..aec7b51 100644 --- a/apps/web/src/app/nav.ts +++ b/apps/web/src/app/nav.ts @@ -13,7 +13,7 @@ export type ListScreen = * from "Vault ready" (D198). */ export type SettingsFocus = - 'backup' | 'passkey' | 'recovery' | 'devices' | 'pair' | 'readmit' | 'refound'; + 'backup' | 'passkey' | 'recovery' | 'devices' | 'pair' | 'readmit' | 'refound' | 'import'; export type Screen = | ListScreen diff --git a/apps/web/src/i18n/messages.ts b/apps/web/src/i18n/messages.ts index 2a1f4c3..b400649 100644 --- a/apps/web/src/i18n/messages.ts +++ b/apps/web/src/i18n/messages.ts @@ -364,6 +364,10 @@ const en = { 'A record on {name} failed verification, so the sync stopped: {name} names it. Nothing was merged.', }, }, + crash: { + title: 'Something went wrong', + text: 'BlindVault hit an unexpected error and locked the vault. Nothing saved was lost: it stays on this device.', + }, ready: { title: 'Vault ready', intro: 'Your vault is on this device. Before you start:', @@ -600,7 +604,13 @@ const en = { allItems: 'All items', items: 'Items', detail: 'Details', - empty: 'No items yet.', + firstSteps: 'First steps', + firstStepsIntro: 'Your vault lives only on your devices. To start:', + stepCreated: 'Create the vault and keep its Recovery Phrase', + stepItem: 'Add your first item: a login, a card, a secure note…', + stepImport: 'Import from another password manager.', + stepItemAction: 'Add an item', + stepImportAction: 'Import', nothingSelected: 'Select an item.', trash: 'Trash', trashEmpty: 'The trash is empty.', @@ -664,6 +674,9 @@ const en = { "The duplicate's title would be longer than 512 characters. Shorten the title first.", history: 'History', moreActions: 'More actions', + saved: 'Saved.', + trashed: 'Moved to the trash.', + restored: 'Restored.', otherFields: 'Other fields', strength: 'Strength: {level}.', strengthLevels: { weak: 'Weak', fair: 'Fair', strong: 'Strong' }, @@ -1406,6 +1419,10 @@ const ptBR: Messages = { 'Um registro em {name} falhou na verificação, então a sincronização parou: {name} diz qual. Nada foi mesclado.', }, }, + crash: { + title: 'Algo deu errado', + text: 'O BlindVault encontrou um erro inesperado e bloqueou o cofre. Nada do que foi salvo se perdeu: continua neste dispositivo.', + }, ready: { title: 'Cofre pronto', intro: 'Seu cofre está neste dispositivo. Antes de começar:', @@ -1643,7 +1660,13 @@ const ptBR: Messages = { allItems: 'Todos os itens', items: 'Itens', detail: 'Detalhes', - empty: 'Nenhum item ainda.', + firstSteps: 'Primeiros passos', + firstStepsIntro: 'Seu cofre vive só nos seus dispositivos. Para começar:', + stepCreated: 'Criar o cofre e guardar a Frase de Recuperação', + stepItem: 'Criar o primeiro item: um login, um cartão, uma nota segura…', + stepImport: 'Importar de outro gerenciador de senhas.', + stepItemAction: 'Criar um item', + stepImportAction: 'Importar', nothingSelected: 'Selecione um item.', trash: 'Lixeira', trashEmpty: 'A lixeira está vazia.', @@ -1706,6 +1729,9 @@ const ptBR: Messages = { copyTitleTooLong: 'O título da duplicata passaria de 512 caracteres. Encurte o título antes.', history: 'Histórico', moreActions: 'Mais ações', + saved: 'Salvo.', + trashed: 'Movido para a lixeira.', + restored: 'Restaurado.', otherFields: 'Outros campos', strength: 'Força: {level}.', strengthLevels: { weak: 'Fraca', fair: 'Razoável', strong: 'Forte' }, diff --git a/apps/web/src/ui/ErrorBoundary.tsx b/apps/web/src/ui/ErrorBoundary.tsx new file mode 100644 index 0000000..31d4935 --- /dev/null +++ b/apps/web/src/ui/ErrorBoundary.tsx @@ -0,0 +1,56 @@ +import { Component, type ReactNode } from 'react'; + +import { clipboard } from '../app/clipboard'; +import { type VaultController } from '../app/controller'; +import { useMessages } from '../i18n/context'; +import { Button } from './components'; +import { Icon } from './icons'; + +/** The failure screen: nothing of the vault, only a reload. */ +function Failure() { + const m = useMessages(); + return ( +
+ + + +

{m.crash.title}

+

{m.crash.text}

+ +
+ ); +} + +/** + * A render error anywhere under it locks the vault, clearing its plaintext state and the clipboard, + * before the failure screen paints (D250): `componentDidCatch` runs in the commit, ahead of the + * browser's paint, and the screens that held plaintext are already unmounted. + */ +export class ErrorBoundary extends Component< + { readonly controller: VaultController; readonly children: ReactNode }, + { readonly failed: boolean } +> { + override state = { failed: false }; + + static getDerivedStateFromError(): { failed: boolean } { + return { failed: true }; + } + + override componentDidCatch(): void { + this.props.controller.lock(); + clipboard.lock(); + } + + override render(): ReactNode { + return this.state.failed ? : this.props.children; + } +} diff --git a/apps/web/src/ui/FirstSteps.tsx b/apps/web/src/ui/FirstSteps.tsx new file mode 100644 index 0000000..496a4e7 --- /dev/null +++ b/apps/web/src/ui/FirstSteps.tsx @@ -0,0 +1,79 @@ +import { type VaultSession } from '@blindvault/vault'; +import { useId } from 'react'; + +import { type Navigation, type SettingsFocus } from '../app/nav'; +import { useMessages } from '../i18n/context'; +import { Button } from './components'; +import { Icon } from './icons'; + +/** + * The empty vault's first steps (D250), in place of "No items yet.": the vault made, then a first + * item, an import and a pairing, each opening where it is done. A read-only vault leaves out the + * steps that write. + */ +export function FirstSteps({ + session, + nav, +}: { + readonly session: VaultSession; + readonly nav: Navigation; +}) { + const m = useMessages(); + const id = useId(); + const settings = (focus: SettingsFocus) => () => { + nav.open({ name: 'settings', focus }); + }; + const steps = [ + ...(session.writable + ? [ + { + key: 'item', + text: m.vault.stepItem, + action: m.vault.stepItemAction, + go: () => { + nav.select({ name: 'new' }); + }, + }, + { + key: 'import', + text: m.vault.stepImport, + action: m.vault.stepImportAction, + go: settings('import'), + }, + ] + : []), + { key: 'pair', text: m.ready.pair, action: m.ready.pairAction, go: settings('pair') }, + ]; + return ( +
+

+ {m.vault.firstSteps} +

+

{m.vault.firstStepsIntro}

+
    +
  1. + + + + {m.vault.stepCreated} +
  2. + {steps.map((step, i) => ( +
  3. + +
    + {step.text} + +
    +
  4. + ))} +
+
+ ); +} diff --git a/apps/web/src/ui/ImportItems.tsx b/apps/web/src/ui/ImportItems.tsx index ad4e281..fd87cb6 100644 --- a/apps/web/src/ui/ImportItems.tsx +++ b/apps/web/src/ui/ImportItems.tsx @@ -84,8 +84,10 @@ export function ImportItems({ }; return ( -
-

{m.import.title}

+
+

+ {m.import.title} +

{m.import.intro}

{!session.writable ? ( {m.import.readOnly} diff --git a/apps/web/src/ui/ItemDetail.tsx b/apps/web/src/ui/ItemDetail.tsx index 6ae1821..8d68543 100644 --- a/apps/web/src/ui/ItemDetail.tsx +++ b/apps/web/src/ui/ItemDetail.tsx @@ -17,6 +17,7 @@ import { Icon, type IconName } from './icons'; import { Avatar, TEMPLATE_ICON, known } from './ItemRow'; import { SecretView } from './Secret'; import { WeakBadge, rated } from './Strength'; +import { useToast } from './Toast'; import { TotpView } from './Totp'; /** A field's name: the user's label, else its role's localized name (D165). */ @@ -135,6 +136,7 @@ export function ItemDetail({ const { messages: m, language } = useLanguage(); const [error, setError] = useState(); const [working, setWorking] = useState(false); + const toast = useToast(); const view = session.item(id); if (!view) return null; const { item } = view; @@ -143,13 +145,14 @@ export function ItemDetail({ // it is, since the copy takes a fresh HLC (D140). const duplicable = item !== undefined && !view.inTrash && session.writable; - const act = (write: () => Promise) => { + const act = (write: () => Promise, done: string) => { setWorking(true); setError(undefined); write().then( () => { onChange(); nav.close(id); + toast(done); }, (e: unknown) => { setWorking(false); @@ -241,7 +244,7 @@ export function ItemDetail({