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 aroundauthenticate({ mediation: "conditional" }). Owns its ownAbortController; aborts on unmount, on a fresh option-change remount, and (optionally) on the consumer-supplied<input>'sblurwhen 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 }.readyistrueonceisConditionalMediationAvailable()resolvestrueAND the conditionalauthenticatecall has been dispatched.erroris the most recent translated error ornull.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 forwardsonBlurto the hook'sabort. AcceptsinputPropsso 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 coveringREACT_AUTH.AUTOFILL_UNAVAILABLE(browser missingisConditionalMediationAvailable),REACT_AUTH.AUTOFILL_FAILED(underlyingWEBAUTHN.*cause),REACT_AUTH.ABORTED(caller-driven abort),REACT_AUTH.SSR_NO_OP(hook ran on server — reported viaonErroronly whenonErroris supplied, otherwise silently no-ops),REACT_AUTH.INVALID_OPTIONS(Zod boundary rejection ofUsePasskeyAutofillOptions). Every code is thrown by exactly one production path. - ADDED:
UsePasskeyAutofillOptionsSchema— Zod schema validated at hook entry; mismatches throwREACT_AUTH.INVALID_OPTIONSsynchronously (so React surfaces it via the nearest error boundary at mount time, not silently in an effect). - ADDED:
AbortSignalchaining — caller-suppliedoptions.signalis composed with the hook's internal controller so either source aborts the underlyingnavigator.credentials.get. Internal controller fires on unmount and onabort().
SSR + feature detection
- ADDED:
useEffect-gated entry point — the hook performs no work on the server render. On the first client mount it callsPublicKeyCredential.isConditionalMediationAvailable?.()via@suaveplan/webauthn'sisConditionalMediationAvailablehelper. If the static method is missing or returnsfalse, the hook staysready === false, emitsREACT_AUTH.AUTOFILL_UNAVAILABLEviaonError(if supplied), and never dispatchesauthenticate. - ADDED: stable
abortreference — returnedabortis wrapped withuseCallbackand a controller ref, so consumers can put it inuseEffectcleanup 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_AUTHcode 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/authand@suaveplan/vaultown that. The hook expects the caller to have already obtained{ rpId, challenge }from the server. - Non-autofill WebAuthn React hooks —
usePasskeyRegister,usePasskeyAuthenticate(modal),useVaultUnlockland in follow-up changes under@suaveplan/react-auth. - WebAuthn Level 2 polyfill — Conditional UI is L3-only; older browsers surface
REACT_AUTH.AUTOFILL_UNAVAILABLEand 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-authpost-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.