Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion apps/web/e2e/erase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,8 @@ test('erases the unlocked vault from Settings, saying it is the only device (D11

await startScreen(page);
await expect(page.getByTestId('erased')).toContainText('The vault was erased from this device.');
await expect(page.getByTestId('lock-state')).toHaveText('Locked');
// No vault left, so no lock state (D254).
await expect(page.getByTestId('lock-state')).toHaveCount(0);
expect(await databases(page)).toEqual([]);

// Not kept anywhere: a reload shows the start screen alone.
Expand Down
9 changes: 7 additions & 2 deletions apps/web/e2e/recovery-phrase.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,13 @@ test('shows the phrase once, asks for three of its words, and shows it again on
{ exact: true },
),
).toBeVisible();
// No copy or download control on the phrase screen: only the wizard's Cancel (D253).
await expect(page.getByRole('button')).toHaveText(['I wrote them down', 'Cancel']);
// No copy or download control on the phrase screen: only the Preview badge and the wizard's
// Cancel (D253, D254).
await expect(page.getByRole('main').getByRole('button')).toHaveText([
'Preview',
'I wrote them down',
'Cancel',
]);
await expect(page.getByRole('link')).toHaveCount(0);

await button(page, 'I wrote them down').click();
Expand Down
4 changes: 2 additions & 2 deletions apps/web/e2e/refounding.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,8 +106,6 @@ 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);
Expand All @@ -126,6 +124,8 @@ test('re-founds the vault: warnings, proof, new phrase and password, and only th
await button(page, 'Back').click();
await button(page, 'Back').click();
await expect(button(page, 'ACME')).toBeVisible();
// The new vault also shows the backup reminder, at the top of the list (D188, D254).
await expect(page.getByTestId('backup-reminder')).toBeVisible();
await expect(button(page, 'Discarded')).toHaveCount(0);
await button(page, 'Trash').click();
await expect(button(page, 'Discarded')).toHaveCount(0);
Expand Down
6 changes: 4 additions & 2 deletions apps/web/e2e/screen-reader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ for (const [layout, width] of WIDTHS) {

await startScreen(page);
await expect(main).toMatchAriaSnapshot(`
- text: BlindVault Locked
- button "Preview"
- heading "BlindVault" [level=1]
- list:
- listitem: /no account and no server/
Expand Down Expand Up @@ -186,7 +186,9 @@ for (const [layout, width] of WIDTHS) {
await page.keyboard.press('Enter');
await expect(page.getByText('Wrong password.')).toBeVisible({ timeout: 30_000 });
await expect(main).toMatchAriaSnapshot(`
- text: BlindVault Locked
- text: BlindVault
- button "Preview"
- text: Locked
- heading "Unlock the vault" [level=1]
- textbox "Local password" [invalid]
- alert: Wrong password.
Expand Down
6 changes: 4 additions & 2 deletions apps/web/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { expect, test } from '@playwright/test';
import { startScreen } from './create';

test('renders the first screen, locked, with its version', async ({ page }) => {
test('renders the first screen, with no lock state before a vault exists, and its version', async ({
page,
}) => {
await page.goto('/');
await startScreen(page);
await expect(page.getByRole('button', { name: 'Restore from a backup' })).toBeVisible();
await expect(page.getByTestId('lock-state')).toHaveText('Locked');
await expect(page.getByTestId('lock-state')).toHaveCount(0);
await expect(page.getByText(/^v\d+\.\d+\.\d+$/)).toBeVisible();
});
9 changes: 7 additions & 2 deletions apps/web/e2e/storage-state.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,13 @@ test('warns while storage is not kept on the only device, in every layout, until
await page.setViewportSize({ width, height: 800 });
await expect(banner).toHaveAttribute('data-warning', 'not-kept');
await expect(banner).toContainText('this is the only device of the vault');
// Nothing dismisses it.
await expect(banner.getByRole('button')).toHaveCount(0);
// Nothing dismisses it: its one button shows the whole text (D254).
const details = banner.getByRole('button');
await expect(details).toHaveText('Details');
await details.click();
await expect(details).toHaveText('Less');
await expect(details).toHaveAttribute('aria-expanded', 'true');
await details.click();
}

await openSettings(page);
Expand Down
9 changes: 7 additions & 2 deletions apps/web/e2e/vault.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ test('refuses a weak or mismatched password before creating (D71)', async ({ pag
await page.getByTestId('confirm-password').fill(`${PASSWORD}x`);
await expect(page.getByText('The passwords do not match.')).toBeVisible();
await expect(submit).toBeDisabled();
// A badge beside the app's name opens the whole text (D254).
await expect(page.getByTestId('preview-banner')).toBeHidden();
await page.getByTestId('preview-badge').click();
await expect(page.getByTestId('preview-badge')).toHaveAttribute('aria-expanded', 'true');
await expect(page.getByTestId('preview-banner')).toHaveText(
'Preview build: vaults and backups made here may not open in a later version, and no migration is planned for them. Do not keep anything here you cannot lose.',
);
Expand All @@ -45,7 +49,7 @@ test('creates, locks, refuses a wrong password and unlocks, keeping the URL and
page,
}) => {
await create(page);
await expect(page.getByTestId('preview-banner')).toBeVisible();
await expect(page.getByTestId('preview-badge')).toHaveText('Preview');
const before = await page.evaluate(() => window.history.length);
await page.getByRole('button', { name: 'Settings' }).first().click();
await page.getByRole('button', { name: 'Back', exact: true }).click();
Expand Down Expand Up @@ -193,6 +197,7 @@ test.describe('in Portuguese', () => {
await expect(create.or(advice)).toBeVisible();
if (await advice.isVisible()) await advice.click();
await expect(create).toBeVisible();
await expect(page.getByTestId('lock-state')).toHaveText('Bloqueado');
// No vault yet, so no lock state; the preview badge is translated (D254).
await expect(page.getByTestId('preview-badge')).toHaveText('Prévia');
});
});
6 changes: 6 additions & 0 deletions apps/web/src/i18n/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ const en = {
continue: 'I verified, continue',
},
preview: {
badge: 'Preview',
banner:
'Preview build: vaults and backups made here may not open in a later version, and no migration is planned for them. Do not keep anything here you cannot lose.',
},
Expand All @@ -51,6 +52,8 @@ const en = {
'This copy of BlindVault runs under a sub-path of a github.io address, which other sites share: they can read and delete its storage. Do not keep anything here; use a copy served from its own address.',
},
storage: {
details: 'Details',
less: 'Less',
title: 'This device',
kept: 'The browser keeps this vault’s storage, even when space runs low.',
notKept:
Expand Down Expand Up @@ -1197,6 +1200,7 @@ const ptBR: Messages = {
continue: 'Verifiquei, continuar',
},
preview: {
badge: 'Prévia',
banner:
'Versão de prévia: cofres e backups feitos aqui podem não abrir numa versão futura, e não há migração prevista para eles. Não guarde aqui nada que você não possa perder.',
},
Expand All @@ -1205,6 +1209,8 @@ const ptBR: Messages = {
'Esta cópia do BlindVault roda num subcaminho de um endereço github.io, que outros sites compartilham: eles podem ler e apagar os dados dela. Não guarde nada aqui; use uma cópia servida no próprio endereço.',
},
storage: {
details: 'Detalhes',
less: 'Menos',
title: 'Este dispositivo',
kept: 'O navegador mantém o armazenamento deste cofre, mesmo quando falta espaço.',
notKept:
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/ui/ItemList.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { type Item, type TreeItem, type VaultSession } from '@blindvault/vault';
import { type RefObject, useRef, useState, useSyncExternalStore } from 'react';
import { type ReactNode, type RefObject, useRef, useState, useSyncExternalStore } from 'react';

import { findNode, treeItems } from '../app/folders';
import { hex } from '../app/ids';
Expand Down Expand Up @@ -40,6 +40,7 @@ export function ItemList({
query,
onQuery,
searchRef,
notice,
}: {
readonly session: VaultSession;
readonly nav: Navigation;
Expand All @@ -48,6 +49,8 @@ export function ItemList({
readonly query: string;
readonly onQuery: (query: string) => void;
readonly searchRef: RefObject<HTMLInputElement | null>;
/** Shown below the list's head, such as the backup reminder (D254). */
readonly notice?: ReactNode;
}) {
const { messages: m, language } = useLanguage();
const layout = useLayout();
Expand Down Expand Up @@ -207,6 +210,7 @@ export function ItemList({
</div>
) : null}
</div>
{notice}
{list.name === 'folder' && !searching ? (
<FolderActions
// Keyed by folder: an open form never carries to another folder.
Expand Down
80 changes: 48 additions & 32 deletions apps/web/src/ui/VaultShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import {
LockState,
Notice,
Overflow,
PreviewBanner,
PreviewBadge,
SharedOriginWarning,
TARGET,
UpdateBanner,
} from './components';
import { useLayout } from './hooks';
Expand Down Expand Up @@ -110,7 +111,9 @@ function Header({
// Under "Vault ready" a new item would open unseen (D198).
const adds = isList(screen) && screen.name !== 'trash' && session.writable && !controller.ready;
return (
<header className="flex flex-wrap items-center gap-2 border-b border-line px-4 py-2">
<header
className={`flex items-center border-b border-line py-2 ${mobile ? 'gap-1 px-3' : 'gap-2 px-4'}`}
>
{back ? (
<Button
variant="quiet"
Expand All @@ -123,9 +126,14 @@ function Header({
{m.app.back}
</Button>
) : null}
<span className="font-semibold">{m.app.name}</span>
<LockState unlocked />
<span className="flex-1" />
{/* A phone's drill-down keeps Back and the lock state only (D254). */}
{mobile && back ? null : (
<>
<span className="min-w-0 truncate font-semibold">{m.app.name}</span>
<PreviewBadge />
</>
)}
<LockState unlocked className="ms-auto shrink-0 whitespace-nowrap" />
{!mobile && screen.name !== 'settings' ? (
<Button
variant="quiet"
Expand All @@ -141,6 +149,7 @@ function Header({
<>
{adds ? (
<Button
className="px-3.5"
aria-label={m.items.new}
title={`${m.items.new} (N)`}
aria-keyshortcuts="N"
Expand All @@ -151,12 +160,12 @@ function Header({
<Icon name="plus" />
</Button>
) : null}
<Overflow label={m.app.more}>
<Overflow label={m.app.more} className="px-3.5">
<Button variant="quiet" className="text-left" {...lock} />
</Overflow>
</>
) : (
<Button {...lock} />
<Button variant="secondary" {...lock} />
)}
</header>
);
Expand Down Expand Up @@ -284,8 +293,40 @@ export function VaultShell({
window.removeEventListener('keydown', keys);
};
}, [controller, nav, session]);
// At the top of the item list, with its two actions (§13.4, D188, D254).
const backupReminder = reminder ? (
<div
role="note"
className="grid grid-cols-[1fr_auto] items-start gap-2 rounded-card bg-info-soft px-3 py-2.5 text-sm text-info-text"
data-testid="backup-reminder"
>
<p>{reminderText(m, language, reminder)}</p>
<button
type="button"
className={`${TARGET} -me-1 -mt-1 min-w-10 rounded-control hover:bg-subtle`}
aria-label={m.restore.dismiss}
title={m.restore.dismiss}
onClick={() => {
controller.dismissBackupReminder();
}}
>
<Icon name="x" />
</button>
<button
type="button"
className={`${TARGET} col-span-2 justify-self-start rounded-control border border-current px-3 font-medium hover:bg-subtle`}
onClick={() => {
nav.openSettings('backup');
}}
>
<Icon name="upload" className="me-1.5" />
{m.vault.reminderBackup}
</button>
</div>
) : null;
const list = (
<ItemList
notice={backupReminder}
session={session}
nav={nav}
onChange={refresh}
Expand Down Expand Up @@ -415,7 +456,6 @@ export function VaultShell({
<LiveSync controller={controller} nav={nav} />
<div className="flex min-h-0 flex-1 flex-col overflow-y-auto" data-testid="scroller">
<SharedOriginWarning />
<PreviewBanner />
<UpdateBanner />
<StorageWarningBanner controller={controller} session={session} />
{clock ? (
Expand Down Expand Up @@ -488,30 +528,6 @@ export function VaultShell({
</Button>
</div>
) : null}
{reminder ? (
<div
className="flex flex-wrap items-center gap-2 px-4 py-2"
data-testid="backup-reminder"
>
<Notice>{reminderText(m, language, reminder)}</Notice>
<Button
variant="quiet"
onClick={() => {
nav.openSettings('backup');
}}
>
{m.vault.reminderBackup}
</Button>
<Button
variant="quiet"
onClick={() => {
controller.dismissBackupReminder();
}}
>
{m.restore.dismiss}
</Button>
</div>
) : null}
{controller.leftoverPasskey ? (
<div
className="flex flex-wrap items-center gap-2 px-4 py-2"
Expand Down
Loading
Loading