feat(passkeys): offer passkey sign-in on the login form (#41)
The point at which passkeys become visible to customers. Everything before this was reachable only by knowing the endpoints existed. Below the password form rather than above it. Passwords are how every existing customer signs in and a passkey is the alternative, so putting it first would demote the path that works for everyone. Absent entirely where WebAuthn is unavailable, rather than shown disabled. A greyed-out control invites a customer to wonder what they are missing and offers nothing they can act on, and password login is the fallback in every case regardless. The check is read once at render because it decides whether the control exists, not whether pressing it works. The passkey button has its own loading flag rather than sharing the form's. The requirement is that a dismissed prompt leaves a usable password form behind it, and a shared flag would leave that form disabled and spinning while the browser's prompt is open. Dismissing the prompt is a cancellation and shows nothing. NotAllowedError and AbortError are the two the browser raises for it, and reporting either as a failure would tell a customer something went wrong when they changed their mind — leaving a red alert sitting above a form that is working perfectly. Everything else shows a message that says what to do next rather than only that something failed. That message says nothing about whether an account exists, which costs nothing to hold to here because the server already answers every refusal identically. There is also no email on this path at all, so there is nothing to be asked about. The end-to-end test covers the half of this issue that can be proven without an authenticator. The failure is injected at the first request, before the browser prompt, so it needs no credential and cannot hang waiting for a gesture nobody will make — and then the password form behind the error is used to sign in for real. That is the requirement: not a dead end. The other half cannot be tested anywhere but production, and this issue says so itself. Credentials bind to the Relying Party ID, so a passkey registered against QA will not work against production. QA proves the flow, the fallbacks and the copy; production needs its own smoke test with a real registration afterwards, and that is a standing property of the feature rather than a gap in this change. Verified: tsc clean for src and tests, lint clean with no warnings, frontend build green. Closes #41 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
1da4dc7acc
commit
72e8090fd1
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user