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.
+
+ "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.
+
+ "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({
) : (
)}
);
}
+
+/**
+ * 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 (
+