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
1 change: 1 addition & 0 deletions src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ function AuthGate() {
setAuthUiError(null);
try {
const password = await unlockWithStoredPasskey(signal);
if (signal.aborted) return;
await unlock(password);
} catch (err) {
if (err instanceof Error && err.name === 'AbortError') return;
Expand Down
63 changes: 61 additions & 2 deletions src/__tests__/App.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { ReactNode } from 'react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { act, render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { MemoryRouter } from 'react-router';

Expand All @@ -22,6 +22,9 @@ const mocks = vi.hoisted(() => ({
retryInitialization: vi.fn(),
setPhrase: vi.fn(),
createIdentity: vi.fn(),
unlockWithStoredPasskey: vi.fn(),
passkeyConfigured: false,
passkeyController: null as AbortController | null,
}));

vi.mock('@/enbox/provider', () => ({
Expand Down Expand Up @@ -61,6 +64,12 @@ vi.mock('@/enbox/hooks/use-identity-mutations', () => ({
useCreateIdentity: () => ({ mutateAsync: mocks.createIdentity }),
}));

vi.mock('@/lib/passkeys', () => ({
canUsePasskeyUnlock: () => true,
hasStoredPasskeyCredential: () => mocks.passkeyConfigured,
unlockWithStoredPasskey: mocks.unlockWithStoredPasskey,
}));

vi.mock('@/features/auth/SetupIdentityStep', () => ({
SetupIdentityStep: ({
onCreateIdentity,
Expand All @@ -86,12 +95,39 @@ vi.mock('@/stores/backup-seed-store', () => ({
}));

vi.mock('@/features/auth/UnlockScreen', () => ({
UnlockScreen: ({ onForgotPin }: { onForgotPin: () => void }) => (
UnlockScreen: ({
onForgotPin,
onUnlockWithPasskey,
passkeyConfigured,
passkeyAvailable,
}: {
onForgotPin: () => void;
onUnlockWithPasskey?: (signal: AbortSignal) => Promise<void>;
passkeyConfigured?: boolean;
passkeyAvailable?: boolean;
}) => (
<section>
<h1>Unlock Mock</h1>
<button type="button" onClick={onForgotPin}>
Forgot PIN
</button>
{passkeyConfigured && passkeyAvailable && (
<>
<button
type="button"
onClick={() => {
const controller = new AbortController();
mocks.passkeyController = controller;
void onUnlockWithPasskey?.(controller.signal);
}}
>
Unlock using passkey
</button>
<button type="button" onClick={() => mocks.passkeyController?.abort()}>
Cancel passkey request
</button>
</>
)}
</section>
),
}));
Expand Down Expand Up @@ -149,7 +185,11 @@ describe('App auth restore flow', () => {
mocks.authState.agent = null;
mocks.authState.dwnEndpoints = ['https://wallet-default.example/dwn'];
mocks.authState.error = null;
mocks.passkeyConfigured = false;
mocks.passkeyController = null;
mocks.restore.mockResolvedValue(undefined);
mocks.unlock.mockResolvedValue(undefined);
mocks.unlockWithStoredPasskey.mockResolvedValue('vault-password');
mocks.createIdentity.mockResolvedValue({ did: { uri: 'did:dht:alice' } });
});

Expand Down Expand Up @@ -187,6 +227,25 @@ describe('App auth restore flow', () => {
expect(screen.getByRole('heading', { name: 'Unlock Mock' })).toBeInTheDocument();
});

it('does not unlock after passkey cancellation while password retrieval is still pending', async () => {
let finishPasswordRetrieval!: (password: string) => void;
mocks.passkeyConfigured = true;
mocks.unlockWithStoredPasskey.mockImplementation(() => new Promise((resolve) => {
finishPasswordRetrieval = resolve;
}));
const user = userEvent.setup();
renderApp();

await user.click(screen.getByRole('button', { name: 'Unlock using passkey' }));
await waitFor(() => expect(mocks.unlockWithStoredPasskey).toHaveBeenCalledOnce());
await user.click(screen.getByRole('button', { name: 'Cancel passkey request' }));
expect(mocks.passkeyController?.signal.aborted).toBe(true);

await act(async () => finishPasswordRetrieval('vault-password'));

expect(mocks.unlock).not.toHaveBeenCalled();
});

it('renders DWeb Connect before empty-wallet identity onboarding', async () => {
mocks.authState.unlocked = true;
mocks.authState.agent = { agentDid: { uri: 'did:dht:agent' } } as never;
Expand Down
19 changes: 18 additions & 1 deletion src/features/auth/UnlockScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export function UnlockScreen({
}
}, [isLoading, onUnlockWithPasskey]);

const handleCancelPasskeyUnlock = useCallback(() => {
if (isLoading) return;
passkeyAbortRef.current?.abort();
}, [isLoading]);

const showPasskey = passkeyConfigured;
const canUsePasskey = showPasskey && passkeyAvailable && onUnlockWithPasskey;

Expand All @@ -78,7 +83,19 @@ export function UnlockScreen({
}

if (passkeyLoading) {
return <Loader message="Waiting for passkey approval..." />;
return (
<div className="flex w-full flex-col items-center gap-4">
<Loader message="Waiting for passkey approval..." />
<Button
type="button"
variant="secondary"
size="sm"
onClick={handleCancelPasskeyUnlock}
>
Cancel passkey request
</Button>
</div>
);
}

if (canUsePasskey) {
Expand Down
37 changes: 37 additions & 0 deletions src/features/auth/__tests__/UnlockScreen.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,43 @@ describe('UnlockScreen', () => {
expect(requestSignal?.aborted).toBe(true);
});

it('lets the user cancel a passkey request that never presents and retry', async () => {
let firstSignal: AbortSignal | undefined;
let attempt = 0;
const onUnlockWithPasskey = vi.fn((signal: AbortSignal) => {
attempt += 1;
if (attempt > 1) {
return Promise.resolve();
}
firstSignal = signal;
return new Promise<void>((_resolve, reject) => {
signal.addEventListener(
'abort',
() => reject(new DOMException('The operation was aborted', 'AbortError')),
{ once: true },
);
});
});
const user = userEvent.setup();

render(
<UnlockScreen
{...defaults}
passkeyConfigured
passkeyAvailable
onUnlockWithPasskey={onUnlockWithPasskey}
/>,
);

await user.click(screen.getByRole('button', { name: /unlock using passkey/i }));
await user.click(screen.getByRole('button', { name: /cancel passkey request/i }));

expect(firstSignal?.aborted).toBe(true);
const retry = await screen.findByRole('button', { name: /unlock using passkey/i });
await user.click(retry);
expect(onUnlockWithPasskey).toHaveBeenCalledTimes(2);
});

it('does not show PIN input when a passkey wallet is unavailable on this device', () => {
render(
<UnlockScreen
Expand Down
Loading