Files
2026-05-27 23:29:53 +01:00

8.2 KiB

@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 <input> 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 <PasskeyAutofillField> wrapper that renders an <input autocomplete="username webauthn"> 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 <input>'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: <PasskeyAutofillField> — a thin presentational <input autocomplete="username webauthn"> 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 <input> 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 <input autocomplete="username webauthn">. The optional <PasskeyAutofillField> 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.