Merge pull request 'feat(passkeys): list, add and revoke from the account page (#40)' (#334) from feature/40-manage-passkeys into main
Linting / lint (push) Canceled after 0s
SonarQube Analysis / sonarqube (push) Canceled after 0s

Reviewed-on: #334
This commit was merged in pull request #334.
This commit is contained in:
2026-09-09 15:44:49 -05:00
4 changed files with 297 additions and 0 deletions
+94
View File
@@ -9,6 +9,7 @@ import { asyncRoute } from '../asyncRoute';
import { requireCustomer } from '../middleware/customerAuth';
import { relyingParty } from '../passkeys/relyingParty';
import { defaultCredentialName, readCredentialName } from '../passkeys/credentialName';
import { readId } from '../utils';
const router = Router();
@@ -192,6 +193,99 @@ router.post(
})
);
/**
* The customer's registered passkeys (#40).
*
* Registering one with no way to see or remove it is worse than not offering
* passkeys at all, which is what makes this the smallest issue in the project
* and the one that makes the rest usable.
*
* `last_used_at` is here because it is the only thing that tells two entries
* apart when the names are similar — a customer about to revoke one needs to
* know which device they are cutting off, and "used an hour ago" answers that
* where a creation date does not.
*/
router.get(
'/',
requireCustomer,
asyncRoute(async (req: Request, res: Response) => {
const { rows } = await pool.query<{
id: number;
name: string;
created_at: Date;
last_used_at: Date | null;
}>(
`SELECT id, name, created_at, last_used_at
FROM customer_credentials
WHERE customer_id = $1
ORDER BY created_at DESC`,
[req.customerId]
);
// No public key, no credential id, no counter. The customer cannot act on
// any of them, and a credential id is the one value that identifies this
// authenticator to anyone who has it.
res.json(rows);
})
);
router.delete(
'/:id',
requireCustomer,
asyncRoute(async (req: Request, res: Response) => {
const id = readId(req.params.id);
if (id === null) return res.status(404).json({ error: 'not found' });
// Removing the last way in must not lock the customer out.
//
// This cannot fire today: password_hash is NOT NULL, so every customer has
// a password and removing every passkey still leaves them a way to sign in.
// The issue asks for the check anyway, and that is the right call — it is
// written against the condition rather than against today's schema, so it
// starts holding on its own the moment the condition changes.
//
// #332 is what changes it. Social sign-in makes password_hash nullable and
// creates the first customers with no password, at which point a customer
// whose only credential is a passkey can genuinely lock themselves out with
// this button. When that lands, `has_password` stops being always true and
// this branch starts running.
const { rows: waysIn } = await pool.query<{ has_password: boolean; credentials: string }>(
`SELECT (c.password_hash IS NOT NULL) AS has_password,
(SELECT count(*) FROM customer_credentials WHERE customer_id = c.id) AS credentials
FROM customers c
WHERE c.id = $1`,
[req.customerId]
);
const waysInRow = waysIn[0];
if (waysInRow && !waysInRow.has_password && Number(waysInRow.credentials) <= 1) {
return res.status(409).json({
error:
'that is the only way you can sign in — set a password first, or add another passkey'
});
}
// Scoped to the signed-in customer in the same statement that deletes.
// Reading first and deleting after would leave a window, and a credential
// id is not a secret — the only thing making this safe is that the WHERE
// names whose it must be.
//
// Revocation is the row going away: #39 looks the credential up by id on
// every sign-in, so a deleted one is refused immediately and by
// construction rather than by a flag something has to remember to check.
const { rowCount } = await pool.query(
`DELETE FROM customer_credentials WHERE id = $1 AND customer_id = $2`,
[id, req.customerId]
);
// 404 for both "no such credential" and "not yours", deliberately. The
// second is the interesting case and saying so would confirm that some
// other customer holds that id.
if (rowCount === 0) return res.status(404).json({ error: 'not found' });
res.status(204).end();
})
);
/** Exported for the tests; nothing else constructs a challenge. */
export { CHALLENGE_TTL_MS };
+6
View File
@@ -11,6 +11,7 @@ import { updateConsent, updateAnalyticsConsent, exportMyData, deleteMyAccount, r
import { setFavoriteAlerts } from './favoritesApi';
import { useCustomerAuth } from './CustomerAuthContext';
import AccountDetails from './AccountDetails';
import Passkeys from './Passkeys';
const { Text } = Typography;
@@ -172,6 +173,11 @@ export default function Account({ onClose }: Props) {
</div>
</div>
<Divider />
{/* Renders nothing where WebAuthn is unavailable, so a browser that
cannot do this is not offered a button that fails (#40). */}
<Passkeys />
<Divider />
<Space wrap>
{/* Order history is a page of its own now. The link stays here because
+140
View File
@@ -0,0 +1,140 @@
import { useCallback, useEffect, useState } from 'react';
import Button from 'antd/es/button';
import List from 'antd/es/list';
import Space from 'antd/es/space';
import Typography from 'antd/es/typography';
import Popconfirm from 'antd/es/popconfirm';
import message from 'antd/es/message';
import { Passkey, fetchPasskeys, registerPasskey, revokePasskey } from './customerApi';
const { Text } = Typography;
/**
* The passkeys section of the account page (#40).
*
* Registering a passkey with no way to see or remove it is worse than not
* offering passkeys at all, which is the whole reason this exists.
*
* Rendered only where WebAuthn is available. A browser without it gets nothing
* rather than a button that cannot work — the same rule #41 applies to the login
* form, and the reason the check is here rather than inside the click handler.
*/
const SUPPORTED =
typeof window !== 'undefined' && typeof window.PublicKeyCredential === 'function';
function whenUsed(passkey: Passkey): string {
// The thing that actually tells two entries apart when the names are similar.
// A customer about to revoke one needs to know which device they are cutting
// off, and "last used" answers that where a creation date does not.
if (!passkey.last_used_at) return 'never used';
return `last used ${new Date(passkey.last_used_at).toLocaleDateString()}`;
}
export default function Passkeys() {
const [passkeys, setPasskeys] = useState<Passkey[]>([]);
// Seeded from support rather than always true, so the unsupported case needs
// no effect to correct it. Setting it synchronously in the effect below would
// be a state update during render as far as the hooks rule is concerned, and
// suppressing that would be hiding the smell rather than removing it.
const [loading, setLoading] = useState(SUPPORTED);
const [adding, setAdding] = useState(false);
const load = useCallback(() => {
setLoading(true);
return fetchPasskeys()
.then(setPasskeys)
// Distinguished from an empty list on purpose: "you have no passkeys" and
// "we could not find out" look identical otherwise, and the first invites
// a customer to add one they may already have.
.catch(() => message.error('Could not load your passkeys'))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
// `load` raises the pending flag before fetching. The rule cannot tell that
// from a value that was already knowable, and this is the former — the same
// exception CustomerAuthContext and DraftQueue take, for the same reason.
// eslint-disable-next-line react-hooks/set-state-in-effect
if (SUPPORTED) void load();
}, [load]);
if (!SUPPORTED) return null;
async function handleAdd() {
setAdding(true);
try {
setPasskeys(await registerPasskey());
message.success('Passkey added');
} catch (err) {
// Dismissing the browser's prompt rejects, and that is a cancellation
// rather than a failure. Reporting it as an error would tell a customer
// something went wrong when they simply changed their mind.
const name = (err as { name?: string }).name;
if (name !== 'NotAllowedError' && name !== 'AbortError') {
message.error(`Couldn't add a passkey — ${(err as Error).message}`);
}
} finally {
setAdding(false);
}
}
async function handleRevoke(passkey: Passkey) {
try {
await revokePasskey(passkey.id);
} catch (err) {
// The server's message is shown rather than replaced. Refusing to remove
// the last way in says what to do about it, and a generic message would
// strand the customer on a button that just does not work.
message.error((err as Error).message);
return;
}
message.success(`Removed ${passkey.name}`);
void load();
}
return (
<div style={{ marginTop: 12 }}>
<Space align="center" style={{ marginBottom: 8 }}>
<Text strong>Passkeys</Text>
<Button size="small" loading={adding} onClick={handleAdd}>
Add a passkey
</Button>
</Space>
<div>
<Text type="secondary" style={{ fontSize: 12 }}>
Sign in with your fingerprint, face or screen lock instead of your password.
</Text>
</div>
<List
loading={loading}
dataSource={passkeys}
locale={{ emptyText: 'No passkeys yet' }}
renderItem={(passkey) => (
<List.Item
actions={[
<Popconfirm
key="remove"
title={`Remove ${passkey.name}?`}
description="You will not be able to sign in with this device again."
okText="Remove"
okButtonProps={{ danger: true }}
onConfirm={() => handleRevoke(passkey)}
>
<Button size="small" danger>
Remove
</Button>
</Popconfirm>
]}
>
<List.Item.Meta
title={passkey.name}
description={`Added ${new Date(passkey.created_at).toLocaleDateString()} · ${whenUsed(passkey)}`}
/>
</List.Item>
)}
/>
</div>
);
}
+57
View File
@@ -172,6 +172,63 @@ export function changeMyEmail(currentPassword: string, email: string): Promise<C
}).then(res => handle<Customer>(res));
}
/** A registered passkey, as the account page lists it (#40). */
export interface Passkey {
id: number;
name: string;
created_at: string;
last_used_at: string | null;
}
export function fetchPasskeys(): Promise<Passkey[]> {
return fetch('/api/customers/me/passkeys').then(res => handle<Passkey[]>(res));
}
/**
* Registers a passkey on this device.
*
* Both halves of the ceremony live here rather than in the component, because
* they are one operation: options come from the server, the browser turns them
* into an attestation, and the server verifies it. A component holding the
* intermediate state could leave a challenge issued and never answered.
*
* `startRegistration` is what prompts the customer. It throws when they dismiss
* that prompt, which is a cancellation rather than a failure — the caller tells
* them apart.
*/
export async function registerPasskey(name?: string): Promise<Passkey[]> {
const { startRegistration } = await import('@simplewebauthn/browser');
const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' });
const options = await handle<Parameters<typeof startRegistration>[0]['optionsJSON']>(optionsRes);
const attestation = await startRegistration({ optionsJSON: options });
const finishRes = await fetch('/api/customers/me/passkeys/register/finish', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...attestation, name })
});
await handle<{ name: string }>(finishRes);
// The fresh list rather than the one row, so the caller cannot render a list
// that disagrees with the server about what was just added.
return fetchPasskeys();
}
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
// message matters here — refusing to remove the last way in says what to do
// about it, and replacing that with something generic would strand the
// customer on a button that simply does not work.
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || 'Request failed');
}
});
}
export function resendVerificationEmail(): Promise<void> {
// 204 on success, so handle() would throw parsing an empty body. The failure
// path must still reject: the server's message distinguishes "already