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
2 changes: 2 additions & 0 deletions apps/web/e2e/accessibility.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ test('names the generator, describes a field by its error, and reads a mask and
// The generator is a named group.
await page.getByRole('button', { name: 'Generate', exact: true }).click();
await expect(page.getByRole('group', { name: 'Password generator' })).toBeVisible();
await page.keyboard.press('Escape');

// A refused value: the field's error is its description.
await page.getByTestId('item-title').fill('ACME');
Expand Down Expand Up @@ -57,6 +58,7 @@ test('reflows at 320 px with no horizontal scroll: start, list, editor and Setti
await page.getByRole('button', { name: 'Login', exact: true }).click();
await page.getByRole('button', { name: 'Generate', exact: true }).click();
expect(await fits()).toBe(true);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Settings' }).first().click();
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/axe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ for (const [layout, width] of WIDTHS) {
await button(page, 'Login').click();
await button(page, 'Generate').click();
await audit(page, 'the item editor and the generator');
await page.keyboard.press('Escape');
await button(page, 'Save').click();
await expect(page.getByText('Enter a title.')).toBeVisible();
await audit(page, 'the item editor with an error');
Expand All @@ -79,7 +80,6 @@ for (const [layout, width] of WIDTHS) {
await button(page, 'Cancel').click();
await audit(page, 'the discard confirmation');
await button(page, 'Discard the changes').click();
await button(page, 'More actions').click();
await button(page, 'History').click();
await audit(page, 'an item history');

Expand Down
1 change: 0 additions & 1 deletion apps/web/e2e/backup-merge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,6 @@ test('merges a backup made on another installation, keeping the losing edit in h
await button(a, 'ACME').click();
await button(a, 'Show').click();
await expect(a.getByTestId('secret-Password')).toHaveText(ON_B);
await button(a, 'More actions').click();
await button(a, 'History').click();
const versions = a.locator('article').getByRole('listitem');
await expect(versions).toHaveCount(2);
Expand Down
2 changes: 0 additions & 2 deletions apps/web/e2e/duplicate.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,6 @@ for (const [layout, width] of WIDTHS) {
await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible();

// The history says old values stay, and keeps them masked.
await button(page, 'More actions').click();
await button(page, 'History').click();
await expect(page.getByRole('heading', { name: 'History of Harbor' })).toBeVisible();
await expect(page.getByText(/Earlier values stay in this history/)).toBeVisible();
Expand All @@ -65,7 +64,6 @@ for (const [layout, width] of WIDTHS) {
await expect(page.getByText('Work', { exact: true }).last()).toBeVisible();
await button(page, 'Show').click();
await expect(page.getByTestId('secret-Password')).toHaveText(OLD);
await button(page, 'More actions').click();
await button(page, 'History').click();
await expect(page.getByText('No earlier versions.')).toBeVisible();
await button(page, 'Back to the item').click();
Expand Down
1 change: 1 addition & 0 deletions apps/web/e2e/form-history.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ test('keeps vault content out of the form history and the spellchecker (D174, D1
}
await button(page, 'Generate').first().click();
await expectNoneKept(page);
await page.keyboard.press('Escape');
await button(page, 'Cancel').click();
// The added fields are a change: leaving asks first (D234).
await button(page, 'Discard the changes').click();
Expand Down
19 changes: 15 additions & 4 deletions apps/web/e2e/item-detail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@ import { type Page, expect, test } from '@playwright/test';
import { createVault } from './create';

// The item detail (D247): a header with the avatar and chips, fields in titled cards, icon and text
// on a secret's buttons, Duplicate, History and Move to trash under "More actions", and a shown
// seed phrase as a numbered grid.
// on a secret's buttons and icon buttons on the others (D254), Duplicate, History and Move to trash
// under "More actions", and a shown seed phrase as a numbered grid.

test.setTimeout(120_000);

Expand Down Expand Up @@ -57,16 +57,27 @@ test('the detail shows chips, titled cards and the actions menu (D247)', async (
// A secret's buttons carry an icon beside their visible text.
await expect(button(page, 'Show').locator('svg')).toHaveCount(1);
await expect(button(page, 'Copy: Password').locator('svg')).toHaveCount(1);
// Other fields: Copy and Open are icon buttons, named and with a tooltip (D254).
const copyUser = button(page, 'Copy: Username');
await expect(copyUser).toHaveAttribute('title', 'Copy');
await expect(copyUser).toHaveText('');
await expect(page.getByRole('link', { name: 'Open: Website', exact: true })).toHaveAttribute(
'href',
'https://bank.example/login',
);

// Edit is visible; the rest waits under More actions.
await expect(button(page, 'Edit')).toBeVisible();
await expect(button(page, 'Duplicate')).toHaveCount(0);
await button(page, 'More actions').click();
const menu = page.locator(':popover-open');
for (const name of ['Duplicate', 'History', 'Move to trash']) {
await expect(button(page, name)).toBeVisible();
await expect(menu.getByRole('button', { name, exact: true })).toBeVisible();
}
await page.keyboard.press('Escape');
await expect(button(page, 'History')).toHaveCount(0);
await expect(button(page, 'Duplicate')).toHaveCount(0);
// History is also a link at the detail's foot (D254).
await expect(button(page, 'History')).toBeVisible();
});

test('a shown seed phrase is a numbered grid of its words (D247)', async ({ page }) => {
Expand Down
1 change: 0 additions & 1 deletion apps/web/e2e/keyboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,6 @@ for (const [layout, width] of WIDTHS) {
await expect(page.getByTestId('discard-confirm')).toBeVisible();
await press(page, button(page, 'Discard the changes'));
await expect(page.getByRole('heading', { name: 'Harbor', level: 1 })).toBeVisible();
await press(page, button(page, 'More actions'));
await press(page, button(page, 'History'));
await expect(page.getByRole('heading', { name: 'History' }).first()).toBeVisible();

Expand Down
1 change: 1 addition & 0 deletions apps/web/e2e/layout.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -353,6 +353,7 @@ test.describe('on a coarse pointer', () => {
await page.getByRole('button', { name: 'Generate', exact: true }).click();
await expect(page.getByTestId('generator-entropy')).toBeVisible();
expect(await smallTargets(page), `${layout}: the editor and its generator`).toEqual([]);
await page.keyboard.press('Escape');
await page.getByRole('button', { name: 'Cancel', exact: true }).click();
await page.getByRole('button', { name: 'Settings', exact: true }).first().click();
await expect(page.getByRole('heading', { name: 'Settings', level: 1 })).toBeVisible();
Expand Down
1 change: 0 additions & 1 deletion apps/web/e2e/logs.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,6 @@ test('logs nothing, and no secret, through a full use (CRYPTO-INV-005, 006)', as
await page.getByRole('button', { name: 'Edit', exact: true }).click();
await page.getByTestId('input-Password').fill(ROTATED);
await page.getByRole('button', { name: 'Save', exact: true }).click();
await page.getByRole('button', { name: 'More actions', exact: true }).click();
await page.getByRole('button', { name: 'History', exact: true }).click();
await page.getByRole('button', { name: 'Show', exact: true }).first().click();
await expect(page.getByText(SECRET)).toBeVisible();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/e2e/screen-reader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,8 +132,8 @@ for (const [layout, width] of WIDTHS) {
- button "Show"
- button "Generate"
- alert: Fix the fields marked above.
- button "Save"
- button "Cancel"
- button "Save"
`);

// An item: the secret is read as "Hidden", and a copy is announced with its clipboard note.
Expand Down
3 changes: 0 additions & 3 deletions apps/web/e2e/sync.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,7 +440,6 @@ test('concurrent edits converge in one sync: the newer wins on both, the other i
for (const page of [a, b]) {
await expect(button(page, 'ACME on A')).toHaveCount(0);
await button(page, 'ACME on B').click();
await button(page, 'More actions').click();
await button(page, 'History').click();
// A's edit lost on both, and both keep it, above the first state.
const versions = page.locator('article').getByRole('listitem');
Expand Down Expand Up @@ -478,7 +477,6 @@ test('the history cut drops an edit of A in one sync: only A tells its user, and
await closed(a, b);
for (const page of [a, b]) {
await button(page, title).click();
await button(page, 'More actions').click();
await button(page, 'History').click();
const versions = page.locator('article').getByRole('listitem');
await expect(versions).toHaveCount(20);
Expand Down Expand Up @@ -516,7 +514,6 @@ test('an edit older than a trash never brings the item back: in the trash on bot
await expect(page.getByText('The trash is empty.')).toBeVisible();
await button(page, 'All items').click();
await button(page, 'ACME').click();
await button(page, 'More actions').click();
await button(page, 'History').click();
const versions = page.locator('article').getByRole('listitem');
await expect(versions.filter({ hasText: 'ACME on A' })).toHaveCount(1);
Expand Down
1 change: 0 additions & 1 deletion apps/web/e2e/write-navigation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,6 @@ test('another item chosen while a version is restored stays shown (D210)', async
await page.getByTestId('item-title').fill('ACME 2');
await button(page, 'Save').click();
await expect(heading(page, 'ACME 2', 1)).toBeVisible();
await button(page, 'More actions').click();
await button(page, 'History').click();
await together(button(page, 'Restore this version'), button(page, 'Beta'));
await expect(button(page, 'ACME')).toBeVisible();
Expand Down
41 changes: 26 additions & 15 deletions apps/web/src/ui/Generator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,13 @@ function entropy(s: GeneratorSettings): number | null {
* The password generator of the item editor (overview §13.6). The generated value goes straight
* into the field being edited; only the settings are kept.
*/
export function Generator({ onUse }: { readonly onUse: (value: string) => void }) {
export function Generator({
onUse,
onCancel,
}: {
readonly onUse: (value: string) => void;
readonly onCancel: () => void;
}) {
const m = useMessages();
const [settings, setSettings] = useState(loadGeneratorSettings);
// Counts typed as text, so a value can be typed through an invalid one.
Expand All @@ -46,8 +52,8 @@ export function Generator({ onUse }: { readonly onUse: (value: string) => void }
};
const bits = entropy(settings);
return (
<fieldset className="flex flex-col gap-3 rounded-control border border-line p-3">
<legend className="sr-only">{m.generator.title}</legend>
<fieldset className="flex flex-col gap-3">
<legend className="mb-3 text-lg font-semibold">{m.generator.title}</legend>
<div className="flex gap-4 text-sm">
{(['characters', 'words'] as const).map((mode) => (
<label key={mode} className={`${TARGET} flex items-center gap-2`}>
Expand Down Expand Up @@ -129,18 +135,23 @@ export function Generator({ onUse }: { readonly onUse: (value: string) => void }
? m.generator.unavailable
: format(m.generator.entropy, { bits: String(Math.floor(bits)) })}
</p>
<Button
disabled={bits === null}
onClick={() => {
const generated =
settings.mode === 'words'
? generatePassphrase(settings.words)
: generatePassword(settings.length, settings.sets);
onUse(generated.value);
}}
>
{m.generator.use}
</Button>
<div className="flex flex-wrap justify-end gap-2 max-sm:flex-col-reverse">
<Button variant="secondary" onClick={onCancel}>
{m.items.cancel}
</Button>
<Button
disabled={bits === null}
onClick={() => {
const generated =
settings.mode === 'words'
? generatePassphrase(settings.words)
: generatePassword(settings.length, settings.sets);
onUse(generated.value);
}}
>
{m.generator.use}
</Button>
</div>
</fieldset>
);
}
52 changes: 41 additions & 11 deletions apps/web/src/ui/ItemDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,11 @@ import { type Navigation } from '../app/nav';
import { host } from '../app/row';
import { useLanguage } from '../i18n/context';
import { type Messages, format, roleName } from '../i18n/messages';
import { Button, Notice, Overflow } from './components';
import { Button, Notice, Overflow, TARGET } from './components';
import { writeError } from './errors';
import { Icon, type IconName } from './icons';
import { Avatar, TEMPLATE_ICON, known } from './ItemRow';
import { SecretView } from './Secret';
import { SecretView, useCopy } from './Secret';
import { WeakBadge, rated } from './Strength';
import { useToast } from './Toast';
import { TotpView } from './Totp';
Expand All @@ -40,6 +40,9 @@ function openable(value: string): boolean {
}
}

/** An icon button of a field that is not secret: named by its action and the field (D254). */
const ICON_BUTTON = `${TARGET} inline-flex min-w-10 shrink-0 items-center justify-center rounded-control text-fg hover:bg-subtle`;

export function FieldValue({
field,
name,
Expand All @@ -50,6 +53,7 @@ export function FieldValue({
readonly clearSeconds: number;
}) {
const m = useLanguage().messages;
const { copy, checked } = useCopy(clearSeconds);
const { value } = field;
if (typeof value !== 'string') {
return <TotpView value={value} name={name} clearSeconds={clearSeconds} />;
Expand All @@ -66,25 +70,39 @@ export function FieldValue({
)}
</div>
);
case 'url':
default:
return (
<span className="flex items-center gap-2 break-all">
{value}
{openable(value) ? (
<span className="flex items-center gap-1">
<span className="min-w-0 flex-1 break-words whitespace-pre-wrap">{value}</span>
{field.type === 'url' && openable(value) ? (
<a
href={value}
target="_blank"
rel="noopener noreferrer"
referrerPolicy="no-referrer"
className="text-sm font-medium underline"
aria-label={`${m.items.open}: ${name}`}
title={m.items.open}
className={ICON_BUTTON}
>
{m.items.open}
<Icon name="external-link" />
</a>
) : null}
<button
type="button"
aria-label={`${m.items.copy}: ${name}`}
title={m.items.copy}
className={ICON_BUTTON}
onClick={() => {
copy(value);
}}
>
<Icon
name={checked ? 'check' : 'copy'}
className={checked ? 'text-success motion-safe:animate-pop' : ''}
/>
</button>
</span>
);
default:
return <span className="break-words whitespace-pre-wrap">{value}</span>;
}
}

Expand Down Expand Up @@ -251,7 +269,6 @@ export function ItemDetail({
</Button>
) : (
<Button
variant="secondary"
disabled={working}
onClick={() => {
nav.open({ name: 'edit', id });
Expand Down Expand Up @@ -323,6 +340,19 @@ export function ItemDetail({
<p className="px-4 pt-1 pb-3 break-words whitespace-pre-wrap">{item.notes}</p>
</Section>
) : null}
{!view.inTrash ? (
// Beside the menu's entry (D254).
<Button
variant="quiet"
className="self-start text-muted"
onClick={() => {
nav.open({ name: 'history', id });
}}
>
<Icon name="history" className="me-1.5" />
{m.items.history}
</Button>
) : null}
{error ? (
<p role="alert" className="text-sm text-danger">
{error}
Expand Down
Loading
Loading