Merge pull request 'feat(passkeys): offer passkey sign-in on the login form (#41)' (#335) from feature/41-passkey-login-page into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s

Reviewed-on: #335
This commit was merged in pull request #335.
This commit is contained in:
2026-09-09 15:53:19 -05:00
3 changed files with 148 additions and 1 deletions
+68 -1
View File
@@ -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<string | null>(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<unknown>) {
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 && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
@@ -157,6 +197,33 @@ export default function AuthForm({ mode, onModeChange, onForgotPassword, onSucce
<Button type="link" style={{ paddingInline: 0, marginTop: 8 }} onClick={onForgotPassword}>
Forgot password?
</Button>
{/* 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 && (
<>
<Divider plain style={{ marginBlock: 16 }}>
<Text type="secondary" style={{ fontSize: 12 }}>or</Text>
</Divider>
<Button
block
loading={passkeyLoading}
onClick={signInWithAPasskey}
>
Sign in with a passkey
</Button>
<Text type="secondary" style={{ fontSize: 12, display: 'block', marginTop: 8 }}>
{/* 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.
</Text>
</>
)}
</Form>
)
}
+37
View File
@@ -216,6 +216,43 @@ export async function registerPasskey(name?: string): Promise<Passkey[]> {
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<Customer> {
const { startAuthentication } = await import('@simplewebauthn/browser');
const optionsRes = await fetch('/api/customers/passkeys/login/begin', { method: 'POST' });
const options = await handle<Parameters<typeof startAuthentication>[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<Customer>(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<void> {
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
+43
View File
@@ -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();