diff --git a/e2e/04-unlock-password.spec.ts b/e2e/04-unlock-password.spec.ts index 6e302711..d46ac3cb 100644 --- a/e2e/04-unlock-password.spec.ts +++ b/e2e/04-unlock-password.spec.ts @@ -3,7 +3,9 @@ * * Covers § 8.4 of e2e/README.md. Cold-start the app with Alice's * encrypted blob in IndexedDB and `authMethod='seed'` in localStorage, - * so `Home` renders `UnlockScreen`. 4 tests, 4 linux baselines. + * so `Home` renders `UnlockScreen`. 6 tests, 5 linux baselines (the + * reset-flow test is functional-only — the post-wipe screen is the + * Welcome screen already baselined by 01-onboarding-welcome). * * Closes the MVP triage gap noted in README.md (no E2E coverage on * `Unlock wallet — password` previously). @@ -18,6 +20,16 @@ * `data-unlocking` attribute itself is still useful as a synchronous DOM * marker for functional assertions, just not for visual regression. * + * The reset-link branch (PR #132) adds two functional tests that exercise + * the `unlock-reset-btn` escape hatch: + * - `unlock-reset-link-visible`: the link renders next to the password + * form, baselined via `04-unlock-reset-link` so a reflow that hides + * or re-styles it gets caught by visual regression. + * - `unlock-reset-flow-wipes-and-shows-onboarding`: click → accept the + * native confirm → the encrypted wallet is wiped and `Home` falls + * through to ``. Welcome screen is already baselined + * by 01-onboarding-welcome, so this one asserts visibility only. + * * Locators: testid-based. The wrong-password test still asserts on the * literal `Incorrect password` text — there is only one error on this * screen, so a `data-error-kind` discriminator would be redundant, but @@ -95,4 +107,42 @@ test.describe('Unlock wallet — password', () => { await expect(page.getByTestId('wallet-empty-banner')).not.toBeVisible({ timeout: 30_000 }); await snap(page, '04-unlock-success-wallet', { fullPage: true }); }); + + test('unlock-reset-link-visible', async ({ page }) => { + // The "Forgot password? Reset wallet" link renders under the unlock + // form as the escape hatch when the user can't remember their + // password. Baseline the idle state — `04-unlock-empty` already + // covers the form chrome; this snap is taken at a slightly taller + // viewport region to include the link below the submit button. + await arriveAtUnlock(page); + const resetBtn = page.getByTestId('unlock-reset-btn'); + await expect(resetBtn).toBeVisible(); + await expect(resetBtn).toHaveText(/forgot password\? reset wallet/i); + await expect(resetBtn).toBeEnabled(); + await snap(page, '04-unlock-reset-link'); + }); + + test('unlock-reset-flow-wipes-and-shows-onboarding', async ({ page }) => { + // Functional flow only — the post-reset screen (Welcome) is already + // baselined by 01-onboarding-welcome, so this test asserts that the + // reset chain (deleteWallet → deleteCredential → resetAuth) runs to + // completion and `Home` falls through to ``. + test.setTimeout(60_000); + await arriveAtUnlock(page); + + // Auto-accept the browser-native confirm. The disconnect spec uses + // the same `page.once('dialog', …)` pattern (see 05-disconnect.spec.ts + // ::post-disconnect-welcome) — keep the two flows in sync. + page.once('dialog', (dialog) => dialog.accept()); + await page.getByTestId('unlock-reset-btn').click(); + + // After the wipe `Home` re-renders Onboarding. The Welcome heading + // and the create-wallet CTA are both gated on `account=null` + + // `hasStoredWallet=false`, so their visibility proves the reset + // chain ran end-to-end. + await expect(page.getByTestId('welcome-heading')).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId('onboarding-create-btn')).toBeVisible(); + // The unlock chrome must be gone — otherwise the wipe was partial. + await expect(page.getByTestId('unlock-heading')).not.toBeVisible(); + }); }); diff --git a/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-empty-chromium-linux.png b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-empty-chromium-linux.png index 5a0f8c12..971d8d60 100644 Binary files a/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-empty-chromium-linux.png and b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-empty-chromium-linux.png differ diff --git a/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-reset-link-chromium-linux.png b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-reset-link-chromium-linux.png new file mode 100644 index 00000000..971d8d60 Binary files /dev/null and b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-reset-link-chromium-linux.png differ diff --git a/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-typed-chromium-linux.png b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-typed-chromium-linux.png index 1cd4aa78..41a624f6 100644 Binary files a/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-typed-chromium-linux.png and b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-typed-chromium-linux.png differ diff --git a/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-wrong-error-chromium-linux.png b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-wrong-error-chromium-linux.png index f6dd51cc..90dbe913 100644 Binary files a/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-wrong-error-chromium-linux.png and b/e2e/04-unlock-password.spec.ts-snapshots/04-unlock-wrong-error-chromium-linux.png differ diff --git a/public/handbook/de/index.html b/public/handbook/de/index.html index 377228f2..d0b6c8c8 100644 --- a/public/handbook/de/index.html +++ b/public/handbook/de/index.html @@ -1390,6 +1390,25 @@

04Wallet entsperren — Passwort

Erfolgreich entsperrt — Wallet öffnet sich mit aktuellem Stand. +
+
+ unlock-reset-linkL111 +
+
+ reset link +
+
+ "Forgot password? Reset wallet" — Notausgang für User, die ihr Passwort vergessen + haben. Löscht den verschlüsselten Blob + Credential nur auf diesem Gerät; zur + Wiederherstellung wird die 12-Wort-Seed benötigt. +
+
diff --git a/public/handbook/index.html b/public/handbook/index.html index f85744dc..531b5d48 100644 --- a/public/handbook/index.html +++ b/public/handbook/index.html @@ -1391,6 +1391,25 @@

04Unlock wallet — Password

Successfully unlocked — wallet opens with current state.
+
+
+ unlock-reset-linkL111 +
+
+ reset link +
+
+ "Forgot password? Reset wallet" — escape hatch for the user who can't remember their + password. Wipes the encrypted blob + credential on this device only; restoring needs + the 12-word seed. +
+
diff --git a/src/__tests__/components/UnlockWallet.test.tsx b/src/__tests__/components/UnlockWallet.test.tsx index 39623e57..1f19ba9c 100644 --- a/src/__tests__/components/UnlockWallet.test.tsx +++ b/src/__tests__/components/UnlockWallet.test.tsx @@ -7,27 +7,59 @@ * stand up the wallet-store wiring `Home` does. */ -import { describe, it, expect, vi } from 'vitest'; -import { render, screen } from '@testing-library/react'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { UnlockScreen } from '@/components/onboarding/UnlockScreen'; +import { + UnlockScreen, + UNLOCK_RESET_CONFIRM, + UNLOCK_RESET_ERROR, +} from '@/components/onboarding/UnlockScreen'; + +// Toggle the PASSKEY feature flag from individual tests. The +// component imports `FEATURES.PASSKEY` synchronously at render +// time, so we replace the module export rather than mutate a +// frozen object. +const FEATURES_STATE = vi.hoisted(() => ({ PASSKEY: false })); +vi.mock('@/lib/features', () => ({ + FEATURES: FEATURES_STATE, +})); + +// jsdom does not implement `window.confirm` (calling it throws "Not +// implemented"), so each test that touches the reset flow installs a +// default no-op stub here that the per-test `vi.spyOn` can then attach +// to. `vi.restoreAllMocks()` in afterEach removes the spy; we re-install +// the stub before the next test instead of leaking a real implementation +// across tests. +beforeEach(() => { + window.confirm = () => false; +}); + +afterEach(() => { + FEATURES_STATE.PASSKEY = false; + vi.restoreAllMocks(); +}); function renderUnlock( overrides: Partial<{ + authMethod: 'passkey' | 'seed' | null; onUnlockPassword: (pw: string) => Promise; onUnlockPrf: (prf: Uint8Array) => Promise; + onReset: () => Promise; }> = {}, ) { const onUnlockPassword = overrides.onUnlockPassword ?? vi.fn().mockResolvedValue(undefined); const onUnlockPrf = overrides.onUnlockPrf ?? vi.fn().mockResolvedValue(undefined); + const onReset = overrides.onReset ?? vi.fn().mockResolvedValue(undefined); render( , ); - return { onUnlockPassword, onUnlockPrf }; + return { onUnlockPassword, onUnlockPrf, onReset }; } describe('UnlockScreen — password flow', () => { @@ -69,3 +101,113 @@ describe('UnlockScreen — password flow', () => { expect(onUnlockPassword).toHaveBeenCalledWith('goodpassword'); }); }); + +describe('UnlockScreen — reset escape hatch', () => { + it('renders the reset link in the password branch', () => { + renderUnlock(); + const btn = screen.getByTestId('unlock-reset-btn'); + expect(btn).toBeInTheDocument(); + expect(btn).toHaveTextContent(/forgot password\? reset wallet/i); + }); + + it('renders the reset link in the passkey branch', () => { + FEATURES_STATE.PASSKEY = true; + renderUnlock({ authMethod: 'passkey' }); + // Sanity-check we're actually on the passkey branch. + expect(screen.getByTestId('unlock-passkey-btn')).toBeInTheDocument(); + expect(screen.queryByTestId('unlock-password-input')).not.toBeInTheDocument(); + expect(screen.getByTestId('unlock-reset-btn')).toBeInTheDocument(); + }); + + it('prompts the user with the documented confirm copy before resetting', async () => { + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true); + const onReset = vi.fn().mockResolvedValue(undefined); + renderUnlock({ onReset }); + + await user.click(screen.getByTestId('unlock-reset-btn')); + + expect(confirmSpy).toHaveBeenCalledWith(UNLOCK_RESET_CONFIRM); + await waitFor(() => expect(onReset).toHaveBeenCalledTimes(1)); + }); + + it('does NOT call onReset when the user dismisses the confirm', async () => { + const user = userEvent.setup(); + const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false); + const onReset = vi.fn().mockResolvedValue(undefined); + renderUnlock({ onReset }); + + await user.click(screen.getByTestId('unlock-reset-btn')); + + expect(confirmSpy).toHaveBeenCalledWith(UNLOCK_RESET_CONFIRM); + expect(onReset).not.toHaveBeenCalled(); + }); + + it('shows a transient "Resetting…" label while onReset is in flight', async () => { + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + let resolveReset: (() => void) | undefined; + const onReset = vi.fn().mockImplementation( + () => + new Promise((resolve) => { + resolveReset = resolve; + }), + ); + renderUnlock({ onReset }); + + await user.click(screen.getByTestId('unlock-reset-btn')); + + await waitFor(() => { + const btn = screen.getByTestId('unlock-reset-btn'); + expect(btn).toHaveTextContent(/resetting…/i); + // `aria-busy` is the semantic signal that goes with the text + // change — assert both so a future refactor can't silently drop + // the screen-reader hint. + expect(btn).toHaveAttribute('aria-busy', 'true'); + }); + resolveReset?.(); + await waitFor(() => { + const btn = screen.getByTestId('unlock-reset-btn'); + expect(btn).toHaveTextContent(/forgot password/i); + expect(btn).toHaveAttribute('aria-busy', 'false'); + }); + }); + + it('surfaces a user-readable error and clears resetting state when onReset throws', async () => { + const user = userEvent.setup(); + vi.spyOn(window, 'confirm').mockReturnValue(true); + // Suppress the unhandled-rejection log Vitest prints for the + // intentionally-rejecting onReset — the component handles it. + const onReset = vi.fn().mockRejectedValue(new Error('IDB fail')); + renderUnlock({ onReset }); + + await user.click(screen.getByTestId('unlock-reset-btn')); + + expect(await screen.findByTestId('unlock-error')).toHaveTextContent(UNLOCK_RESET_ERROR); + // Button is back to the idle label so the user can retry — and + // aria-busy is cleared. + const btn = screen.getByTestId('unlock-reset-btn'); + expect(btn).toHaveTextContent(/forgot password\? reset wallet/i); + expect(btn).toHaveAttribute('aria-busy', 'false'); + expect(btn).toBeEnabled(); + }); + + it('clears a stale error banner when the user cancels the confirm', async () => { + // Type a wrong password first so the "Incorrect password" banner + // is on screen, then click reset and dismiss the confirm. The + // banner should disappear — leaving it would mislead the user + // about which action just happened. + const user = userEvent.setup(); + const onUnlockPassword = vi.fn().mockRejectedValue(new Error('bad password')); + renderUnlock({ onUnlockPassword }); + + await user.type(screen.getByTestId('unlock-password-input'), 'wrongpass'); + await user.click(screen.getByTestId('unlock-submit-btn')); + expect(await screen.findByTestId('unlock-error')).toHaveTextContent('Incorrect password'); + + vi.spyOn(window, 'confirm').mockReturnValue(false); + await user.click(screen.getByTestId('unlock-reset-btn')); + + await waitFor(() => expect(screen.queryByTestId('unlock-error')).not.toBeInTheDocument()); + }); +}); diff --git a/src/app/page.tsx b/src/app/page.tsx index 2ddb7a8f..f7c17981 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useCallback, useEffect, useState } from 'react'; import { AppShell } from '@/components/AppShell'; import { Onboarding } from '@/components/onboarding/Onboarding'; import { UnlockScreen } from '@/components/onboarding/UnlockScreen'; @@ -8,6 +8,7 @@ import { WalletScreen } from '@/components/screens/WalletScreen'; import { useWalletStore } from '@/stores/wallet'; import { useAuthStore } from '@/stores/auth'; import { useCapabilities } from '@/stores/capabilities'; +import { deleteCredential } from '@/lib/crypto/storage'; export default function Home() { const { @@ -18,8 +19,9 @@ export default function Home() { checkForStoredWallet, unlockWithPassword, unlockWithPrf, + deleteWallet, } = useWalletStore(); - const { hydrate } = useAuthStore(); + const { hydrate, reset: resetAuth } = useAuthStore(); const [hydrated, setHydrated] = useState(false); useEffect(() => { @@ -35,6 +37,19 @@ export default function Home() { })(); }, [checkForStoredWallet, hydrate]); + // Escape hatch for users stranded on the unlock screen (forgotten + // password / passkey gone). Reuses the disconnect chain from + // `/settings` so both flows wipe the same surfaces: encrypted + // wallet blob in IndexedDB, passkey credential record, auth-store + // state. `deleteWallet` clears `hasStoredWallet` + `isLocked` in + // the wallet store, so the next render falls through to + // `` automatically — no reload needed. + const handleReset = useCallback(async () => { + await deleteWallet(); + await deleteCredential(); + resetAuth(); + }, [deleteWallet, resetAuth]); + if (!hydrated) return null; if (account && !isLocked) { @@ -51,6 +66,7 @@ export default function Home() { authMethod={storedAuthMethod} onUnlockPassword={unlockWithPassword} onUnlockPrf={unlockWithPrf} + onReset={handleReset} /> ); } diff --git a/src/components/onboarding/UnlockScreen.tsx b/src/components/onboarding/UnlockScreen.tsx index 2b860522..947031f9 100644 --- a/src/components/onboarding/UnlockScreen.tsx +++ b/src/components/onboarding/UnlockScreen.tsx @@ -7,6 +7,27 @@ import { useAuthStore } from '@/stores/auth'; import { authenticatePasskey } from '@/lib/crypto/passkey'; import { FEATURES } from '@/lib/features'; +/** + * Confirm copy shown before wiping the local encrypted wallet. The + * unlock screen is the only place a user with a forgotten password + * can escape — make sure they understand the device-local nature of + * the wipe and the seed-phrase requirement to restore. + * + * @internal Exported for the component test. Do not import from app code. + */ +export const UNLOCK_RESET_CONFIRM = + "Reset wallet? This deletes the encrypted wallet on this device. You'll need your 12-word seed phrase to restore it. This cannot be undone."; + +/** + * Surface message when `onReset` throws. The caller's reset chain + * (deleteWallet → deleteCredential → resetAuth) is mostly idempotent, + * but a partial failure can leave IDB in a half-wiped state — telling + * the user to reload is the safest recovery path. + * + * @internal Exported for the component test. Do not import from app code. + */ +export const UNLOCK_RESET_ERROR = 'Reset failed. Reload the page and try again.'; + /** * Unlock screen — rendered by `Home` when an encrypted wallet is in * IndexedDB but no in-memory account exists yet. @@ -14,19 +35,29 @@ import { FEATURES } from '@/lib/features'; * Extracted from `src/app/page.tsx` so it can be unit-tested in * isolation (issue #68 W1). The prop bag is the natural boundary — * the screen knows nothing about Zustand. + * + * `onReset` is the escape hatch for users who forgot their password + * (or whose passkey is no longer available). It wipes the encrypted + * wallet + credential on this device so `Home` can fall back to the + * Onboarding flow on the next render. The caller (Home) owns the + * actual delete chain — this component only triggers it after an + * explicit user confirmation. */ export function UnlockScreen({ authMethod, onUnlockPassword, onUnlockPrf, + onReset, }: { authMethod: 'passkey' | 'seed' | null; onUnlockPassword: (password: string) => Promise; onUnlockPrf: (prfOutput: Uint8Array) => Promise; + onReset: () => Promise; }) { const [password, setPassword] = useState(''); const [error, setError] = useState(null); const [unlocking, setUnlocking] = useState(false); + const [resetting, setResetting] = useState(false); const credentialId = useAuthStore((s) => s.credentialId); const handlePasswordUnlock = useCallback(async () => { @@ -57,6 +88,28 @@ export function UnlockScreen({ } }, [credentialId, onUnlockPrf]); + const handleReset = useCallback(async () => { + if (typeof window === 'undefined') return; + if (!window.confirm(UNLOCK_RESET_CONFIRM)) { + // User backed out — clear any previous "Incorrect password" / + // "Reset failed" banner so the screen is back to its idle state. + setError(null); + return; + } + setResetting(true); + setError(null); + try { + await onReset(); + } catch { + // Don't surface the underlying error class — the user can't act + // on it. The IDB delete chain is mostly idempotent, so a reload + // usually completes the wipe and lands them in Onboarding. + setError(UNLOCK_RESET_ERROR); + } finally { + setResetting(false); + } + }, [onReset]); + return (
@@ -76,7 +129,7 @@ export function UnlockScreen({
) : (
{unlocking ? 'Unlocking…' : 'Unlock'} @@ -118,9 +172,42 @@ export function UnlockScreen({ {error}

)} + )}
); } + +/** + * Secondary, low-emphasis affordance — by design NOT a second primary + * button. Unlock remains the primary action; reset is the escape + * hatch for users who can't unlock (forgotten password / passkey + * gone). Rendered as a plain text button so the visual hierarchy + * keeps users on the unlock path unless they deliberately deviate. + */ +function ResetLink({ + onClick, + disabled, + busy, +}: { + onClick: () => void; + disabled: boolean; + busy: boolean; +}) { + return ( +
+ +
+ ); +}