Merge pull request 'feat(passkeys): offer passkey sign-in on the login form (#41)' (#335) from feature/41-passkey-login-page into main
Reviewed-on: #335
This commit was merged in pull request #335.
This commit is contained in:
@@ -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