diff --git a/frontend/src/customer/AuthForm.tsx b/frontend/src/customer/AuthForm.tsx index 879c0d3..74f31d1 100644 --- a/frontend/src/customer/AuthForm.tsx +++ b/frontend/src/customer/AuthForm.tsx @@ -6,7 +6,8 @@ import Checkbox from 'antd/es/checkbox'; import Tabs from 'antd/es/tabs'; import Alert from 'antd/es/alert'; import Typography from 'antd/es/typography'; -import { registerCustomer, loginCustomer } from './customerApi'; +import Divider from 'antd/es/divider'; +import { registerCustomer, loginCustomer, signInWithPasskey, passkeysSupported } from './customerApi'; import { useCustomerAuth } from './CustomerAuthContext'; const { Text } = Typography; @@ -49,8 +50,17 @@ type Props = Readonly<{ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) { const [error, setError] = useState(null); const [loading, setLoading] = useState(false); + // Separate from `loading`, so the password button does not sit disabled and + // spinning while the browser's passkey prompt is open. The whole requirement + // is that a dismissed prompt leaves a usable password form behind it. + const [passkeyLoading, setPasskeyLoading] = useState(false); const { refresh } = useCustomerAuth(); + // Read once at render rather than per click: a browser either implements + // WebAuthn or it does not, and this decides whether the control exists at all + // rather than whether pressing it works. + const canUsePasskeys = passkeysSupported(); + async function submit(action: () => Promise) { setLoading(true); setError(null); @@ -65,6 +75,36 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce } } + /** + * Sign in with a passkey (#41). + * + * Not routed through `submit`, because the two differ in the one place that + * matters: dismissing the browser's prompt rejects, and that is a + * cancellation rather than a failure. Showing an error there would tell a + * customer something went wrong when they changed their mind, and would leave + * a red alert sitting above a password form that is working perfectly. + * + * Every other outcome clears back to the password form rather than a dead + * end. The server answers every refusal identically — no such credential, a + * disabled account, a bad assertion — so this cannot say whether an account + * exists, and neither can the copy here. + */ + async function signInWithAPasskey() { + setPasskeyLoading(true); + setError(null); + try { + await signInWithPasskey(); + refresh(); + onSuccess(); + } catch (err) { + const name = (err as { name?: string }).name; + if (name === 'NotAllowedError' || name === 'AbortError') return; + setError('That passkey did not work. You can log in with your password instead.'); + } finally { + setPasskeyLoading(false); + } + } + return ( <> {error && } @@ -157,6 +197,33 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce + + {/* Below the password form, not above it. Passwords are how + every existing customer signs in, and a passkey is the + alternative — putting it first would demote the path that + works for everyone. Absent entirely where WebAuthn is not + available, rather than shown disabled: a greyed button + invites a customer to wonder what they are missing (#41). */} + {canUsePasskeys && ( + <> + + or + + + + {/* Says what it needs rather than naming the standard. + "WebAuthn" means nothing to a customer, and the thing + they recognise is the gesture their device asks for. */} + Use your fingerprint, face or screen lock. + + + )} ) } diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts index ba16012..cba1994 100755 --- a/frontend/src/customer/customerApi.ts +++ b/frontend/src/customer/customerApi.ts @@ -216,6 +216,43 @@ export async function registerPasskey(name?: string): Promise { return fetchPasskeys(); } +/** + * Signs in with a passkey (#41). + * + * Usernameless: nothing is sent to `begin`, and the browser offers whichever + * accounts it holds. The customer never types an address, which is also why + * this cannot leak whether one has an account — there is nothing to ask about. + * + * Rejects when the customer dismisses the prompt, which callers must treat as a + * cancellation rather than a failure. + */ +export async function signInWithPasskey(): Promise { + const { startAuthentication } = await import('@simplewebauthn/browser'); + + const optionsRes = await fetch('/api/customers/passkeys/login/begin', { method: 'POST' }); + const options = await handle[0]['optionsJSON']>(optionsRes); + + const assertion = await startAuthentication({ optionsJSON: options }); + + const finishRes = await fetch('/api/customers/passkeys/login/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(assertion) + }); + return handle(finishRes); +} + +/** + * Whether this browser can do WebAuthn at all. + * + * Checked before offering the control rather than inside its handler, so a + * browser that cannot do this is never shown a button that fails. Password + * login stays the fallback in every case (#41). + */ +export function passkeysSupported(): boolean { + return typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function'; +} + export function revokePasskey(id: number): Promise { return fetch(`/api/customers/me/passkeys/${id}`, { method: 'DELETE' }).then(async (res) => { // 204 on success, so handle() would throw on an empty body. The failure diff --git a/frontend/tests/e2e/auth.spec.ts b/frontend/tests/e2e/auth.spec.ts index 934fa0d..e6f51db 100755 --- a/frontend/tests/e2e/auth.spec.ts +++ b/frontend/tests/e2e/auth.spec.ts @@ -76,6 +76,49 @@ test.describe('Customer accounts', () => { await expect(accountModal.emailText(email)).toBeVisible(); }); + // #41's requirement, and the half of it that can be proven without a real + // authenticator. Credentials bind to the Relying Party ID, so an actual + // passkey sign-in cannot be exercised here — but "a failed passkey prompt + // must return the customer to a usable password form, not a dead end" is + // about what happens *after* the attempt, and that is entirely testable. + test('a failed passkey attempt leaves the password form working', async ({ + page, + customer, + accountModal, + authModal, + header + }) => { + // The fixture leaves the customer signed in, and this test is about the + // signed-out login form. + await accountModal.openAndLogOut(); + await expect(header.logInButton).toBeVisible(); + + await authModal.gotoLogIn(); + + const passkeyButton = authModal.logInDialog.getByRole('button', { + name: 'Sign in with a passkey' + }); + // Chromium implements WebAuthn, so the control is offered here. A browser + // without it gets no button at all rather than a disabled one. + await expect(passkeyButton).toBeVisible(); + + // Failed at the first request, before the browser prompt — so this needs no + // authenticator and cannot hang waiting for a gesture nobody will make. + await page.route('**/api/customers/passkeys/login/begin', (route) => + route.fulfill({ status: 500, contentType: 'application/json', body: '{"error":"nope"}' }) + ); + await passkeyButton.click(); + + // Says what to do next rather than only that something failed, and says + // nothing about whether an account exists. + await expect(page.getByText(/log in with your password instead/i)).toBeVisible(); + + // The actual requirement: not a dead end. The form behind the error still + // signs the customer in. + await authModal.logIn(customer.email, customer.password); + await header.waitForSignedIn(); + }); + test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => { await accountModal.openAndLogOut(); await expect(header.logInButton).toBeVisible();