diff --git a/backend/src/routes/passkeys.ts b/backend/src/routes/passkeys.ts
index 2b00f0a..6fda703 100644
--- a/backend/src/routes/passkeys.ts
+++ b/backend/src/routes/passkeys.ts
@@ -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 };
diff --git a/frontend/src/customer/Account.tsx b/frontend/src/customer/Account.tsx
index b3e028a..57507c7 100755
--- a/frontend/src/customer/Account.tsx
+++ b/frontend/src/customer/Account.tsx
@@ -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) {
+
+ {/* Renders nothing where WebAuthn is unavailable, so a browser that
+ cannot do this is not offered a button that fails (#40). */}
+
+
{/* Order history is a page of its own now. The link stays here because
diff --git a/frontend/src/customer/Passkeys.tsx b/frontend/src/customer/Passkeys.tsx
new file mode 100644
index 0000000..0bd9cfd
--- /dev/null
+++ b/frontend/src/customer/Passkeys.tsx
@@ -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([]);
+ // 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 (
+
+
+ Passkeys
+
+
+
+
+
+ Sign in with your fingerprint, face or screen lock instead of your password.
+
+
+ );
+}
diff --git a/frontend/src/customer/customerApi.ts b/frontend/src/customer/customerApi.ts
index 0b9b7d4..ba16012 100755
--- a/frontend/src/customer/customerApi.ts
+++ b/frontend/src/customer/customerApi.ts
@@ -172,6 +172,63 @@ export function changeMyEmail(currentPassword: string, email: string): Promise handle(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 {
+ return fetch('/api/customers/me/passkeys').then(res => handle(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 {
+ const { startRegistration } = await import('@simplewebauthn/browser');
+
+ const optionsRes = await fetch('/api/customers/me/passkeys/register/begin', { method: 'POST' });
+ const options = await handle[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 {
+ 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 {
// 204 on success, so handle() would throw parsing an empty body. The failure
// path must still reject: the server's message distinguishes "already