# `@suaveplan/react-auth` — `usePasskeyAutofill` React hook **Package**: `@suaveplan/react-auth` (NEW) **Status**: PROPOSED **Type**: new-package **Date**: 2026-05-17 - **Design**: ./design.md - **Spec delta**: ./specs/react-auth/spec.md ## Why The archived `enterprise-webauthn` change deferred a React hook target named `usePasskeyAutofill` to a follow-up `@suaveplan/react-auth` change (see `openspec/archive/2026-05-11-enterprise-webauthn/proposal.md` lines 85-86, 117, 191 and `tasks.md` §2.7.3 / §0 "Out of scope"). The underlying capability — `authenticate({ mediation: "conditional" })` plus `isConditionalMediationAvailable()` — shipped on `@suaveplan/webauthn` and is exported from `packages/auth/webauthn/src/authenticate/authenticate.ts` and `packages/auth/webauthn/src/index.ts`, so the consumer-side hook is now unblocked. Without this hook, every genesis React app that wants WebAuthn Level 3 Conditional UI ("passkey autofill" — the browser surfaces credentials in the username `` autocomplete sheet) has to hand-roll the same wiring: SSR guard, feature detect, `AbortController` lifecycle, unmount cancellation, blur cancellation, and error-code translation. Hand-rolled versions get the abort lifecycle wrong (they leak credential requests across mounts) or skip the `isConditionalMediationAvailable()` check (which throws on Safari < 17 and every SSR environment). Shipping the hook once, in a dedicated package, removes that footgun. `@suaveplan/react-auth` does not exist yet. This change creates it as a lightweight browser-tier package with a single React 19 peer dep, exporting the hook and a tiny optional `` wrapper that renders an `` with the hook pre-wired. The package is deliberately scoped to the autofill surface only; future auth-related React hooks (passkey-as-master-key-unlock, OAuth callback, magic-link landing) land in follow-up changes under the same package. ## What changes ### New package: `@suaveplan/react-auth` Browser-tier package at `packages/auth/react-auth/`. React 19 peer dep, `@suaveplan/webauthn` workspace dep. #### Hook - ADDED: `usePasskeyAutofill(options: UsePasskeyAutofillOptions): UsePasskeyAutofillReturn` — declarative hook around `authenticate({ mediation: "conditional" })`. Owns its own `AbortController`; aborts on unmount, on a fresh option-change remount, and (optionally) on the consumer-supplied ``'s `blur` when no credential has been delivered. - ADDED: `UsePasskeyAutofillOptions` — `{ rpId: string; challenge: Uint8Array; onCredential: (result: AuthenticateResult) => void; onError?: (error: SuaveplanError) => void; signal?: AbortSignal; allowCredentials?: PublicKeyCredentialDescriptor[]; userVerification?: UserVerificationRequirement; timeout?: number; expectedOrigin?: string; expectedRpIdHash?: Uint8Array }`. All option fields are surfaced as a named interface (per CLAUDE.md rule 11 — no inline shapes). - ADDED: `UsePasskeyAutofillReturn` — `{ ready: boolean; error: SuaveplanError | null; abort: () => void }`. `ready` is `true` once `isConditionalMediationAvailable()` resolves `true` AND the conditional `authenticate` call has been dispatched. `error` is the most recent translated error or `null`. `abort()` is a stable callback that cancels the in-flight request without unmounting the component. #### Optional component wrapper - ADDED: `` — a thin presentational `` wrapper that wires the hook to the input and forwards `onBlur` to the hook's `abort`. Accepts `inputProps` so the consumer can style and label the input freely. The component is opt-in: callers may instead use the hook directly with their own `` markup. - ADDED: `PasskeyAutofillFieldProps` — named interface for the component's prop shape. #### Errors / observability / boundaries - ADDED: `defineCodes("REACT_AUTH", [...])` registry covering `REACT_AUTH.AUTOFILL_UNAVAILABLE` (browser missing `isConditionalMediationAvailable`), `REACT_AUTH.AUTOFILL_FAILED` (underlying `WEBAUTHN.*` cause), `REACT_AUTH.ABORTED` (caller-driven abort), `REACT_AUTH.SSR_NO_OP` (hook ran on server — reported via `onError` only when `onError` is supplied, otherwise silently no-ops), `REACT_AUTH.INVALID_OPTIONS` (Zod boundary rejection of `UsePasskeyAutofillOptions`). Every code is thrown by exactly one production path. - ADDED: `UsePasskeyAutofillOptionsSchema` — Zod schema validated at hook entry; mismatches throw `REACT_AUTH.INVALID_OPTIONS` synchronously (so React surfaces it via the nearest error boundary at mount time, not silently in an effect). - ADDED: `AbortSignal` chaining — caller-supplied `options.signal` is composed with the hook's internal controller so either source aborts the underlying `navigator.credentials.get`. Internal controller fires on unmount and on `abort()`. #### SSR + feature detection - ADDED: `useEffect`-gated entry point — the hook performs no work on the server render. On the first client mount it calls `PublicKeyCredential.isConditionalMediationAvailable?.()` via `@suaveplan/webauthn`'s `isConditionalMediationAvailable` helper. If the static method is missing or returns `false`, the hook stays `ready === false`, emits `REACT_AUTH.AUTOFILL_UNAVAILABLE` via `onError` (if supplied), and never dispatches `authenticate`. - ADDED: stable `abort` reference — returned `abort` is wrapped with `useCallback` and a controller ref, so consumers can put it in `useEffect` cleanup arrays without re-running the effect on every render. #### Storybook - ADDED: `src/use-passkey-autofill/use-passkey-autofill.stories.tsx` — default story (hook wired into a dummy form), playground story (props as controls), variant stories (unavailable browser, abort-on-blur, error-state). Per CLAUDE.md "Every React component needs `.stories.tsx`". - ADDED: `src/passkey-autofill-field/passkey-autofill-field.stories.tsx` — default / playground / variants. ### Subpath exports - `.` — root barrel: `usePasskeyAutofill`, `UsePasskeyAutofillOptions`, `UsePasskeyAutofillReturn`, `PasskeyAutofillField`, `PasskeyAutofillFieldProps`, error registry. - `./use-passkey-autofill/use-passkey-autofill` — hook + types. - `./passkey-autofill-field/passkey-autofill-field` — component + props. - `./errors` — `REACT_AUTH` code registry. ### Dependencies - `@suaveplan/webauthn workspace:^` — `authenticate`, `isConditionalMediationAvailable`, `AuthenticateResult`. - `@suaveplan/error workspace:^` — `defineCodes`, `SuaveplanError`. - `@suaveplan/types workspace:^` — shared option bundles. - `react ^19.0.0` (peer) — hook surface. - `zod catalog:forms` — boundary validation. - `tslib catalog:build`. ## Out of scope - **Server-side challenge issuance / verification** — `@suaveplan/api-schemas/auth` and `@suaveplan/vault` own that. The hook expects the caller to have already obtained `{ rpId, challenge }` from the server. - **Non-autofill WebAuthn React hooks** — `usePasskeyRegister`, `usePasskeyAuthenticate` (modal), `useVaultUnlock` land in follow-up changes under `@suaveplan/react-auth`. - **WebAuthn Level 2 polyfill** — Conditional UI is L3-only; older browsers surface `REACT_AUTH.AUTOFILL_UNAVAILABLE` and the hook no-ops. - **DOM manipulation** — the hook does not render or query DOM elements directly. The consumer renders the ``. The optional `` is a thin convenience wrapper, not a requirement. ## Impact ### Affected dependents This is a NEW package — no existing dependents. Run `bunx turbo ls --filter=...@suaveplan/react-auth` after landing to confirm. The hook will be adopted by `@suaveplan/web-app` and any genesis-original app that ships a login page, in their own follow-up changes. - No dependents (verified by `bunx turbo ls --filter=...@suaveplan/react-auth` post-landing — currently empty). ## Migration No prior surface — additive only. There is no obsolete contract for consumers to migrate from. The `enterprise-webauthn` change explicitly scoped this hook out with the note "implementation lives in the `react-auth` change folder; this package only ships the underlying `authenticate({ mediation: "conditional" })` surface" — this change fulfils that deferral.