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 Tabs from 'antd/es/tabs';
|
||||||
import Alert from 'antd/es/alert';
|
import Alert from 'antd/es/alert';
|
||||||
import Typography from 'antd/es/typography';
|
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';
|
import { useCustomerAuth } from './CustomerAuthContext';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
@@ -49,8 +50,17 @@ type Props = Readonly<{
|
|||||||
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) {
|
export default function AuthForm({ mode, onModeChange, onForgotPassword, onSuccess }: Props) {
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(false);
|
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();
|
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>) {
|
async function submit(action: () => Promise<unknown>) {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{error && <Alert type="error" showIcon message={error} style={{ marginBottom: 16 }} />}
|
{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}>
|
<Button type="link" style={{ paddingInline: 0, marginTop: 8 }} onClick={onForgotPassword}>
|
||||||
Forgot password?
|
Forgot password?
|
||||||
</Button>
|
</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>
|
</Form>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -216,6 +216,43 @@ export async function registerPasskey(name?: string): Promise<Passkey[]> {
|
|||||||
return fetchPasskeys();
|
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> {
|
export function revokePasskey(id: number): Promise<void> {
|
||||||
return fetch(`/api/customers/me/passkeys/${id}`, { method: 'DELETE' }).then(async (res) => {
|
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
|
// 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();
|
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 }) => {
|
test('rejects login with the wrong password', async ({ page, customer, accountModal, authModal, header }) => {
|
||||||
await accountModal.openAndLogOut();
|
await accountModal.openAndLogOut();
|
||||||
await expect(header.logInButton).toBeVisible();
|
await expect(header.logInButton).toBeVisible();
|
||||||
|
|||||||
Reference in New Issue
Block a user