@dloizides/auth-web 1.12.1 → 1.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,386 +1,501 @@
1
- # Changelog
2
-
3
- ## 1.12.1 (2026-07-17)
4
-
5
- Internal refactor only — **no API change, no behaviour change**. The public
6
- export surface (`src/index.ts`) is byte-identical to 1.12.0; every consumer
7
- (zygos, katalogos, erevna, kefi, ichnos) keeps importing the same symbols with
8
- the same types from the same entry point.
9
-
10
- Two oversized source files from the demo-credential work were split to satisfy
11
- the repo size limits (files < 300 lines, components < 200):
12
-
13
- - **`src/components/labels.ts` (511 lines) → `src/components/labels/`.** The
14
- single label bag is now one file per component family (`loginLabels.ts`,
15
- `forgotPasswordLabels.ts`, `otpLabels.ts`, `pinLabels.ts`, `devicePinLabels.ts`,
16
- `passkeyLabels.ts`, `preferredMethodLabels.ts`, `demoLabels.ts`,
17
- `resetPasswordLabels.ts`) behind a `labels/index.ts` barrel that re-exports the
18
- exact same names, so `./labels` remains a single import site.
19
- - **`src/components/LoginForm.tsx` (253 lines) → render body slimmed.** The
20
- prop-doc-heavy `LoginFormProps` interface moved to a co-located
21
- `LoginForm.types.ts` (re-exported from `LoginForm.tsx`), and the submit branch
22
- logic moved into a unit-tested `useLoginSubmit` hook.
23
-
24
- ### Changed
25
-
26
- - No runtime change. Splitting only; identical bundled output.
27
-
28
- Completes the demo-credential feature for **BFF-fronted apps**, whose demo pair
29
- arrives ASYNChronously from `GET /bff/config` rather than a module-level const (a
30
- const would bake the credentials into a bundle handed to a customer to
31
- self-deploy). Two gaps closed:
32
-
33
- 1. `prefillOnMount` was inert for async credentials (1.11.0 bug).
34
- 2. `useBffLoginConfig().config` dropped the `demo` field the BFF now serves.
35
-
36
- Requires `@dloizides/auth-client` **>= 4.2.0** (which surfaces `demo` on
37
- `BffLoginConfig`) — the peer floor is raised accordingly. Additive for existing
38
- consumers: the new `LoginForm` prop is optional and the synchronous /
39
- module-const path is byte-identical.
40
-
41
- ### Fixed
42
-
43
- - **`prefillOnMount` now honours credentials that arrive AFTER mount.** `useState`
44
- reads its initial argument on the first render only, so a demo pair landing a
45
- render later (every BFF app) was silently dropped and the form stayed empty
46
- beside a populated hint. `useDemoCredentials` now seeds when the resolved pair
47
- transitions to non-null (or to a genuinely different pair), gated by VALUE so a
48
- react-query refetch handing back a fresh-but-equal object does not re-seed on a
49
- loop.
50
-
51
- ### Added
52
-
53
- - **`config.demo` carries through `useBffLoginConfig`.** `BffLoginConfig` gains
54
- `demo?: DemoCredentials | null`, so the intended wiring works end-to-end:
55
- ```tsx
56
- const { config } = useBffLoginConfig(bffAuthClient);
57
- const demo = useDemoCredentials({ credentials: config.demo ?? undefined, prefillOnMount: true });
58
- ```
59
- - **`PrefillIntent`** enum + **`LoginForm` gains `prefillIntent`** — the "don't
60
- fight the user" control. An `Opportunistic` re-seed (a late config fetch) is
61
- DROPPED unless both fields are still empty, so it never overwrites what a real
62
- account holder is typing while `/bff/config` is in flight; an `Explicit` re-seed
63
- (the user tapped "use these credentials") always applies. `useDemoCredentials`
64
- sets the right intent automatically — most apps never touch this prop.
65
- - **`DemoCredentials` is now re-exported from `@dloizides/auth-client`** (its
66
- canonical owner) rather than defined locally, so there is exactly ONE shape
67
- across the two packages. Structurally identical to 1.11.0 — no consumer change.
68
-
69
- ### "Don't fight the user" — what the hook can and cannot know
70
-
71
- `useDemoCredentials` does not own the input fields (`<LoginForm>` does), so it
72
- cannot observe keystrokes. The interaction test it CAN make is on the field
73
- values: an opportunistic seed applies only when both fields are still empty
74
- (pristine). This is intentionally conservative — a user who typed and then
75
- cleared a field keeps control for the rest of the session (a dropped
76
- opportunistic bump is not retried). An explicit user request is never subject to
77
- this test.
78
-
79
- ## 1.11.0 (2026-07-17)
80
-
81
- Additive release adding **public demo-credential support** for products with an
82
- open, published demo account (Zygos is the first). Purely additive — every 1.10.0
83
- export is unchanged, and an app that imports none of the new exports renders
84
- byte-identically: the three new `<LoginForm>` props are all optional and inert
85
- when omitted.
86
-
87
- It fills form fields and nothing else. **No auth bypass**: the demo login travels
88
- the ordinary path (same BFF, same Keycloak, same session cookie, same roles), and
89
- no credential ships inside this package — the host supplies the pair.
90
-
91
- ### Added
92
-
93
- - **`<DemoCredentialsHint>`** — a themed panel for the login card showing a
94
- published demo username + password with a one-tap "use these credentials"
95
- action. Takes the usual `AuthTheme` / `labels` / `testIdPrefix` props. Values
96
- render as selectable monospace chips (an `l`/`1` in a published password must
97
- not be a guess). Omit `onUse` for a read-only panel.
98
-
99
- **Fails closed**: no credentials — or a half-configured pair (blank username,
100
- blank password, or a field that is `undefined` at runtime because an env var
101
- was never set) — renders `null`. Zero DOM. A misconfigured demo can never
102
- publish an empty credentials panel.
103
-
104
- - **`useDemoCredentials({ credentials?, prefillOnMount? })`** — the hint↔form
105
- wiring, so no app hand-rolls it. Returns two prop bags to spread:
106
- `hintProps` (`{ credentials, onUse }`) and `loginFormProps`
107
- (`{ initialUsername, initialPassword, prefillNonce }`).
108
-
109
- `prefillOnMount` (default `false`) makes the form arrive already filled, so
110
- signing in as the demo user is a single tap on "Sign in".
111
-
112
- - **`resolveDemoCredentials(credentials?)`** — the exported fail-closed gate the
113
- above share: returns a trimmed pair, or `null` when there is no usable demo.
114
-
115
- - **`DemoCredentials`** type and `DEFAULT_DEMO_CREDENTIALS_HINT_LABELS` /
116
- `DemoCredentialsHintLabels` (all copy stays app-supplied, as everywhere else).
117
-
118
- - **`<LoginForm>` gains `initialUsername` / `initialPassword` / `prefillNonce`.**
119
- The `initial*` props **seed** the fields; they do not own them — every value
120
- stays editable and clearable, so a real account holder can always wipe the demo
121
- values and sign in with their own. `prefillNonce` is the re-seed signal:
122
- changing it re-applies the seed over fields the user has edited or cleared.
123
-
124
- The nonce is not ceremony. `useState` ignores its initial argument on
125
- re-render, so a fill button wired to `initialUsername` alone would render a
126
- form that stays stubbornly empty — the nonce is what makes "click the hint →
127
- form fills" work at all. `useDemoCredentials` manages it for you.
128
-
129
- ### Internal
130
-
131
- - `useLoginPrefill` — extracts `<LoginForm>`'s credential field state (the only
132
- part of that component with real logic), so the seed/re-seed contract is unit
133
- tested directly rather than through the JSX.
134
-
135
- ## 1.10.0 (2026-07-17)
136
-
137
- Additive release adding the shared **logout sequencer**. Purely additive — every
138
- 1.9.x export is unchanged, no component behaviour changes, and apps that do not
139
- import `performBffLogout` are byte-identical.
140
-
141
- ### Added
142
-
143
- - **`performBffLogout({ client, onClearSession?, onRedirect?, onError?, navigateToIdp? })`**
144
- — the shared logout sequence, extracted because all six consuming apps
145
- hand-rolled it in their own `AuthProvider` and the orderings disagreed. Runs:
146
- 1. `onClearSession()` — local state first, so the UI reflects the sign-out
147
- immediately.
148
- 2. `await client.logout({ redirect: false })` — lets the `POST /bff/logout`
149
- SETTLE, and takes the IdP logout URL rather than letting the client navigate
150
- mid-sequence.
151
- 3. Navigates exactly once, last: to the IdP logout URL when the BFF returned
152
- one (ending the IdP browser session), otherwise `onRedirect()`. When it
153
- navigates to the IdP it deliberately does NOT also call `onRedirect` — two
154
- competing navigations would race, and the IdP round-trip already returns the
155
- user to the app.
156
-
157
- Never rejects; always navigates, even when the BFF logout fails (a down BFF
158
- must not trap the user on a page they asked to leave).
159
- - **`BffLogoutClient`** / **`PerformBffLogoutOptions`** types.
160
-
161
- ### Why
162
-
163
- `zygos-web` and `agora-web` navigated FIRST and called the BFF second:
164
-
165
- ```ts
166
- redirectTo('/(auth)/login'); // → window.location.replace() → document unload
167
- await bffAuthClient.logout(); // races the unload — may never be sent
168
- ```
169
-
170
- That is wrong twice over:
171
-
172
- 1. **The BFF call can be cancelled.** A document navigation kills in-flight
173
- fetches, so `POST /bff/logout` may never reach the server: the SPA looks
174
- logged out while the httpOnly session cookie stays alive. The `catch` logged
175
- it "non-fatal", so the defect was silent.
176
- 2. **The IdP session survives.** `BffAuthClient.logout()` ends the identity
177
- provider's browser session with a top-level navigation to its RP-initiated
178
- logout URL (only a real navigation carries the IdP's own cookie — a
179
- back-channel POST cannot clear it). An app that navigates to its own login
180
- route first pre-empts that navigation, so the IdP still considers the user
181
- signed in and the next authorize round-trip silently re-authenticates them —
182
- the "sign-out that signs you back in" bug the client's own docs warn about.
183
-
184
- Synthetic tests that stub the client never reproduce either failure (there is no
185
- real navigation to cancel the request), which is why they survived. The helper
186
- owns the sequence so neither can be reintroduced per-app.
187
-
188
- ## 1.8.0 (2026-07-09)
189
-
190
- Additive release for the Move 2b passkey in-app rebuild — the passkey ceremony
191
- now runs **in-page** (`navigator.credentials.*` against the BFF's same-origin
192
- `/bff/passkey/*` fetch endpoints) instead of a full-page redirect to Keycloak.
193
- **Inert by default / no behaviour change until the BFF's WebAuthn RP is
194
- configured:** when the RP is off the options endpoint returns 501 and the hook
195
- transparently falls back to the legacy redirect. No breaking changes; every
196
- 1.7.x export is unchanged and the legacy `startPasskeyLogin` /
197
- `startPasskeyRegistration` navigation helpers stay exported (they ARE the
198
- fallback).
199
-
200
- ### Added
201
-
202
- - **`usePasskeyCeremony({ mode, returnUrl, onSuccess })`** — the react-query-free
203
- in-page ceremony hook: options fetch → `navigator.credentials.create/get` →
204
- verify fetch. Returns `{ status, error, isSupported, start, reset }`. Falls
205
- back to the legacy Keycloak redirect on a 501 (RP not configured), any non-2xx
206
- options response, or an unsupported browser — so nothing regresses until the
207
- owner flips `Bff:Passkey`. A cancelled/timed-out ceremony (`NotAllowedError` /
208
- `AbortError`) surfaces as `'cancelled'`, anything else as `'failed'`.
209
- - **`PasskeyCeremonyMode`** (`Login` / `Register`) and **`PasskeyCeremonyStatus`**
210
- (`Idle` / `Running` / `Success` / `Error`) `const enum`s.
211
-
212
- ### Changed
213
-
214
- - **`<PasskeyLoginButton>`** and **`<PasskeySettingsCard>`** now drive
215
- `usePasskeyCeremony` instead of `window.location.assign`. Same exported props,
216
- same testIDs — a drop-in bump for consumers; the in-page path only activates
217
- once the BFF RP is configured, otherwise they behave exactly as before.
218
-
219
- ## 1.7.0 (2026-07-07)
220
-
221
- Additive release for the Kefi multi-role overhaul — the shared machinery for a
222
- cross-dashboard nav. No breaking changes; every 1.6.x export is unchanged.
223
-
224
- ### Added
225
-
226
- - **`resolveAccessibleRoutes(user, table)`** — the multi-role companion to
227
- `resolvePostLoginRoute`. Returns the full, ordered list of `RoleRoute` entries
228
- the user's role-set unlocks (not just the top one), reusing the same
229
- `collectUserRoles` reader and `RoleRouteTable`. Table (priority) order is
230
- preserved; the table `fallback` is never included; a role-less user gets `[]`.
231
- Pure and app-agnostic — an app renders the result as a cross-dashboard nav.
232
- - **`RoleRoute.labelKey?`** — optional translation key carried per route so a
233
- consuming app can render a localized label per accessible dashboard. Ignored
234
- by `resolvePostLoginRoute`; the package stays i18n-agnostic (carries the key,
235
- the app resolves it).
236
-
237
- ## 1.3.0 (2026-05-31)
238
-
239
- Additive release for the auth-UX reuse roll-out (feature-reuse-roadmap #1).
240
- Promotes the "Forgot password?" modal body the product apps each hand-rolled
241
- into a single shared, **react-query-free** export so katalogos / erevna / kefi
242
- stop duplicating it. No breaking changes; every 1.2.x export is unchanged.
243
-
244
- ### Added
245
-
246
- - **`<ForgotPasswordFields>`** — the embedded, themeable "request a reset link"
247
- body, sized to live inside an app-owned modal shell (no screen/card wrapper,
248
- no title — the host modal owns those). Adds the two modal affordances a
249
- screen-shaped form lacks: a `Cancel` button beside submit and a `Close` button
250
- on the success state, each rendered only when its handler is supplied. Clears
251
- itself when `visible` flips to `false` so reopening starts fresh.
252
- - **`useForgotPasswordSubmit`** — the headless, **react-query-free** forgot-
253
- password logic: a plain `useState` + `client.forgotPassword(...)` promise
254
- chain that runs on a provider-less login route (where the existing
255
- `useBffForgotPassword` `useMutation` crashes with "No QueryClient set"). Email
256
- validation gating via `canSubmit`, anti-enumeration success, `hasNetworkError`,
257
- and `reset()`. Also exports the `isValidForgotPasswordEmail` helper.
258
- - **`ForgotPasswordFieldsLabels`** + **`DEFAULT_FORGOT_PASSWORD_FIELDS_LABELS`** —
259
- the forgot-password label bag plus the modal `cancel` / `close` strings.
260
- - New `AuthTestIds` entries `forgotPasswordCancelButton` /
261
- `forgotPasswordCloseButton`.
262
-
263
- ### Notes
264
-
265
- - The existing screen-shaped `<ForgotPasswordForm>` (react-query-based) is
266
- unchanged. A future cleanup may re-base it on `useForgotPasswordSubmit` to drop
267
- its react-query coupling; deferred to keep this release additive.
268
-
269
- ## 1.2.0 (2026-05-22)
270
-
271
- Additive release for Phase 3d of the unified-auth plan — the event-scoped PIN
272
- UI. Adds the native, branded event-PIN login surface so an app (Kefi) can offer
273
- "sign in with your event PIN" for operational staff (door / DJ / media) without
274
- the user ever bouncing to Keycloak's hosted UI. No breaking changes; every
275
- 1.1.0 export is unchanged.
276
-
277
- ### Added
278
-
279
- - **`<PinForm>`** — the ready-made, themeable single-step event-PIN login form,
280
- built on `usePinLogin`. A PIN field + a "sign in" button; the
281
- `eventExternalId` is a prop (the event context comes from the route/page, it
282
- is never typed by the user). Styled by the same `AuthTheme` token bag as the
283
- other forms; copy is supplied through the i18n-agnostic `PinFormLabels` bag
284
- (English `DEFAULT_PIN_LABELS` fallback). `onSuccess` hands back the signed-in
285
- `BffUser`.
286
- - **`usePinLogin`** — the headless PIN hook (the custom-layout escape hatch). A
287
- single-step flow: `submit(pin)` → `POST /bff/pin/login` → `BffUser`, with
288
- `reset()`. Mirrors `useOtpLogin` (mounted-guard, memoised result,
289
- latest-`eventExternalId` ref).
290
- - **`PinFormLabels`** + **`DEFAULT_PIN_LABELS`** — the PIN label bag.
291
- - New `AuthTestIds` entries for every PIN interactive element.
292
- - Re-exports `BffPinLoginRequest` from `@dloizides/auth-client`.
293
-
294
- ### Changed
295
-
296
- - Peer dependency `@dloizides/auth-client` raised to `>=3.2.0` — the version
297
- that adds `BffAuthClient.pinLogin`.
298
-
299
- ## 1.1.0 (2026-05-22)
300
-
301
- Additive release for Phase 2d of the unified-auth plan — the email-OTP UI. Adds
302
- the native, branded email-OTP login surface so an app can offer "sign in with a
303
- code" without the user ever bouncing to Keycloak's hosted UI. No breaking
304
- changes; every 1.0.0 export is unchanged.
305
-
306
- ### Added
307
-
308
- - **`<OtpForm>`** — the ready-made, themeable two-step email-OTP login form,
309
- built on `useOtpLogin`. Step 1 collects the email and sends a code; step 2
310
- collects the code, verifies it, and offers "resend code" and "use a different
311
- email". Styled by the same `AuthTheme` token bag as the other forms; copy is
312
- supplied through the i18n-agnostic `OtpFormLabels` bag (English
313
- `DEFAULT_OTP_LABELS` fallback). `onSuccess` hands back the signed-in `BffUser`.
314
- - **`useOtpLogin`** — the headless OTP hook (the custom-layout escape hatch). A
315
- small forward state machine over `OtpLoginStep`: `requestCode(identifier)` →
316
- `EnterCode`, then `verifyCode(otp)` → `BffUser`, with `resend()` and
317
- `reset()`. Mirrors `useBffAuth` (mounted-guard, memoised result).
318
- - **`OtpLoginStep`** — the `RequestCode` / `EnterCode` step enum.
319
- - **`OtpFormLabels`** + **`DEFAULT_OTP_LABELS`** — the OTP label bag.
320
- - New `AuthTestIds` entries for every OTP interactive element.
321
- - Re-exports `BffOtpRequestRequest`, `BffOtpVerifyRequest` and
322
- `BffOtpRequestResult` from `@dloizides/auth-client`.
323
-
324
- ### Changed
325
-
326
- - Peer dependency `@dloizides/auth-client` raised to `>=3.1.0` — the version
327
- that ships `BffAuthClient.requestOtp` / `.verifyOtp` (`POST /bff/otp/request`
328
- and `POST /bff/otp/verify`).
329
-
330
- ### Notes
331
-
332
- - Still strictly thin: UI + the same-origin `/bff/*` client. The OTP client
333
- methods carry the `X-BFF-Csrf` header like every other state-changing call;
334
- no token handling, no secrets in the browser.
335
- - This release adds the OTP surface to the package only. Wiring an app onto it
336
- (the Kefi pilot) is Phase 3.
337
-
338
- ## 1.0.0 (2026-05-22)
339
-
340
- Initial release — the frontend half of the unified-auth plan (Phase 1b).
341
-
342
- `@dloizides/auth-web` is the shared, themeable, branded auth UI for the
343
- dloizides.com portfolio. It is extracted and generalised from the per-app auth
344
- code that lived inside `apps/katalogos-web` so katalogos, erevna and kefi can
345
- share one implementation instead of copy-pasting auth adapters.
346
-
347
- ### Added
348
-
349
- - **Themeable components**
350
- - `<LoginForm>` — password login, built on `useBffAuth`.
351
- - `<ForgotPasswordForm>` — request-a-reset-link, built on
352
- `useBffForgotPassword`; no-enumeration confirmation state.
353
- - `<ResetPasswordForm>` — choose-a-new-password, built on
354
- `useResetPasswordForm`.
355
- - All three are styled by an `AuthTheme` design-token bag so visually
356
- distinct apps share one implementation.
357
- - **Theming** — `AuthThemeProvider`, `useAuthTheme`, `defaultAuthTheme`, and
358
- the `AuthTheme` token contract (colors / radii / spacing / typography).
359
- Precedence: explicit `theme` prop → `<AuthThemeProvider>` context → default.
360
- - **i18n-agnostic label bags** — `LoginFormLabels`, `ForgotPasswordFormLabels`,
361
- `ResetPasswordFormLabels` plus English `DEFAULT_*` constants. The package
362
- ships no `t()` / `FM()`; consuming apps pass already-localised strings.
363
- - **Headless hooks** (the custom-layout escape hatch)
364
- - `useBffAuth` — login / logout / `/bff/me` session, lifecycle status.
365
- - `useBffForgotPassword`, `useBffResetPassword` — React Query mutations.
366
- - `useResetPasswordForm` — reset-password form-state + validation logic.
367
- - **`createBffAuthClient`** — one-line wiring of a same-origin `BffAuthClient`
368
- with a lazily-resolved `fetch` (load-time side-effect-free).
369
- - **Role-based post-login router** — `resolvePostLoginRoute(user, table)` and
370
- `collectUserRoles`. The consuming app supplies the role → route table.
371
- - **Password policy** — `validatePasswordPolicy`, `isPasswordValid`,
372
- `PasswordPolicyError`; mirrors the TenantService backend rules.
373
- - **Test IDs** — `AuthTestIds` + `withTestIdPrefix` for E2E targeting.
374
- - Re-exports `BffAuthClient`, `createFetchHttpClient` and the BFF request /
375
- response types from `@dloizides/auth-client` so consumers have a single
376
- import surface.
377
-
378
- ### Notes
379
-
380
- - Strictly thin: UI + a same-origin `/bff/*` fetch client + the router helper.
381
- **No secrets, no token handling, no Keycloak calls** — the per-app BFF owns
382
- all of that server-side.
383
- - Built on `@dloizides/auth-client` (peer dependency `>=3.0.0`), which ships
384
- `BffAuthClient`.
385
- - This release creates the package only. Porting `apps/katalogos-web` onto it
386
- is Phase 1c.
1
+ # Changelog
2
+
3
+ ## 1.14.0 (2026-07-19)
4
+
5
+ Three defects found by measuring the DEPLOYED portals rather than reading the
6
+ source. All three had passing tests over them.
7
+
8
+ ### Fixed
9
+
10
+ - **The auth card no longer vertically centres, so expanding anything stops
11
+ moving the inputs.** Measured on `app.zygos.dloizides.com/login` at 1.13.0:
12
+ expanding the demo disclosure grew the card 445.6 → 589.6px and pulled the
13
+ username input from `y=275.2` to `y=203.2` and the password from `y=368.8` to
14
+ `y=296.8` — **72px UP each**, mid-interaction. `<DemoCredentialsHint>` was
15
+ deliberately mounted BELOW the form to avoid exactly this; centring defeated
16
+ the placement, because a centred card re-centres as it grows and drags
17
+ everything above the growth upward.
18
+
19
+ `useAuthScreenStyles.screen` and `useAuthStyles.screen` are now top-anchored
20
+ (`justifyContent: 'flex-start'` + a 64dp `paddingTop`) instead of centred.
21
+ ⚠️ **This is a visible change on every portal**: the login card now sits below
22
+ a fixed gap rather than floating in the middle of tall viewports.
23
+
24
+ Chosen over the alternatives because the defect is not specific to the
25
+ disclosure — ANY growth shifted the fields (a validation error under an input,
26
+ a passkey button arriving after the config fetch, an OTP tab swap), and only
27
+ top-anchoring fixes the whole class. Reserving the disclosure's height would
28
+ have left ~144px of permanent dead space on every login; absolutely
29
+ positioning the expanded panel would have removed it from the scrollable area,
30
+ making the credentials unreachable on a short viewport. Top-anchoring is also
31
+ strictly safer at small viewports than what it replaces: overflow now runs
32
+ downward only, where centring pushed the top of the card past the top edge
33
+ where no scroll could reach it.
34
+
35
+ - **Selection and expansion states are now announced on the web.** Three
36
+ components declared `accessibilityState` and announced nothing:
37
+ `<DemoCredentialsHint>` (`expanded`), `<DevicePinLengthPicker>` (`selected`)
38
+ and `<PreferredMethodSettingsCard>` (`selected`). Each now passes the ARIA
39
+ attribute **explicitly** (`aria-expanded` / `aria-checked`) alongside the RN
40
+ prop, which is kept for native.
41
+
42
+ Root cause is systemic, not a typo. Measured against react-native-web 0.21.2:
43
+ a `<TouchableOpacity>` given `accessibilityLabel`, `accessibilityRole`,
44
+ `accessibilityHint` and `accessibilityState={{ expanded: true, selected: true }}`
45
+ renders `<button role="button" aria-label="…">` — **no `aria-expanded`, no
46
+ `aria-selected`, and no trace of the hint at all**. `accessibilityState` is not
47
+ read by react-native-web; `accessibilityHint` produces zero DOM output.
48
+
49
+ For `<DevicePinLengthPicker>` the practical effect was WCAG 1.4.1: the chosen
50
+ PIN length was distinguishable only by the pill's fill colour. Its options also
51
+ move from `accessibilityRole="button"` to `"radio"` inside a `radiogroup`,
52
+ since `aria-checked` is only meaningful on a checkable role.
53
+
54
+ - **Field labels now speak the shared contract's `field` voice — 13/600, not
55
+ 14/600.** The forms contract (mirrored in `@dloizides/ui-forms`'
56
+ `labelVariants.ts`) defines exactly two label voices: `field` (13/600, sentence
57
+ case) and `control` (11/700, uppercase). `defaultAuthTheme.typography.label`
58
+ was 14, which is neither — drift, inherited uniformly by every portal, since
59
+ they all reuse `defaultAuthTheme.typography` verbatim. It read against the 13px
60
+ `Field` labels those same apps use on every non-auth form.
61
+
62
+ ### Tests
63
+
64
+ - **New DOM-level guards** (`*.dom.test.tsx`), rendered through the real
65
+ `react-native-web` in a `jsdom` environment, asserting the rendered
66
+ **attribute** rather than the React prop. This is the point: the pre-existing
67
+ suite mocked `react-native` down to host strings and asserted
68
+ `props.accessibilityState` — so it passed, unchanged, against a button that
69
+ announced nothing in every browser. A prop assertion cannot catch this class of
70
+ bug, and did not.
71
+ - `authVisualContract.test.ts` guards the two visual contracts by naming the
72
+ defect they forbid (`justifyContent` must NOT be `'center'`; labels must be
73
+ 13/600 written as a literal, not read back from the token under test).
74
+
75
+ `<DemoCredentialsHint>` gets a **discreet** presentation, and it is now the
76
+ default. The demo stops competing with the sign-in form on login pages that real
77
+ customers use every day.
78
+
79
+ ### Added
80
+
81
+ - **`DemoCredentialsHintVariant`** (`Panel` | `Disclosure`), exported from the
82
+ package root.
83
+ - **`Disclosure` variant** — one quiet, centred "Try the demo" line that expands
84
+ the credentials and the fill button on tap. Collapsed on mount.
85
+ - **`toggle` / `toggleHint` labels** on `DemoCredentialsHintLabels`, so the
86
+ trigger copy is localised by the host like every other string in the package.
87
+ - **`AuthTestIds.demoCredentialsToggle`** (`auth-demo-credentials-toggle`).
88
+
89
+ ### Changed
90
+
91
+ - ⚠️ **`<DemoCredentialsHint>` now defaults to `Disclosure`.** A consumer that
92
+ wants the old always-open block must pass
93
+ `variant={DemoCredentialsHintVariant.Panel}`. No API broke — every existing
94
+ prop, `testID` and callback behaves identically — but the rendered output
95
+ changes, so this is called out as loudly as a breaking change.
96
+ - The credential rows + fill button moved into an internal
97
+ `<DemoCredentialsBody>` shared by both variants, so the two presentations can
98
+ never drift apart.
99
+
100
+ ### Notes on the design
101
+
102
+ Discretion is carried by **size, placement and the absence of chrome** — the
103
+ trigger is caption-sized, centred, and has no fill, border or brand rail. It is
104
+ NOT carried by lowering contrast: the trigger draws `colors.textSecondary`, the
105
+ same AA-held token as the rest of the package's helper copy, so it inherits
106
+ whatever contrast guarantee the consuming app's theme already makes.
107
+
108
+ The trigger stays a real focusable `accessibilityRole="button"` carrying
109
+ `accessibilityState.expanded`, so screen readers announce that it exists and
110
+ whether it is open. Visually quiet is not the same as hidden.
111
+
112
+ The expanded body renders **below** the trigger, and the component is intended to
113
+ sit **below** `<LoginForm>`. Expanding therefore grows the card downward and never
114
+ displaces the inputs the visitor is using — mounting it above the form is what
115
+ would make every expansion shove the fields down the page.
116
+
117
+
118
+ ## 1.12.1 (2026-07-17)
119
+
120
+ Internal refactor only — **no API change, no behaviour change**. The public
121
+ export surface (`src/index.ts`) is byte-identical to 1.12.0; every consumer
122
+ (zygos, katalogos, erevna, kefi, ichnos) keeps importing the same symbols with
123
+ the same types from the same entry point.
124
+
125
+ Two oversized source files from the demo-credential work were split to satisfy
126
+ the repo size limits (files < 300 lines, components < 200):
127
+
128
+ - **`src/components/labels.ts` (511 lines) → `src/components/labels/`.** The
129
+ single label bag is now one file per component family (`loginLabels.ts`,
130
+ `forgotPasswordLabels.ts`, `otpLabels.ts`, `pinLabels.ts`, `devicePinLabels.ts`,
131
+ `passkeyLabels.ts`, `preferredMethodLabels.ts`, `demoLabels.ts`,
132
+ `resetPasswordLabels.ts`) behind a `labels/index.ts` barrel that re-exports the
133
+ exact same names, so `./labels` remains a single import site.
134
+ - **`src/components/LoginForm.tsx` (253 lines) → render body slimmed.** The
135
+ prop-doc-heavy `LoginFormProps` interface moved to a co-located
136
+ `LoginForm.types.ts` (re-exported from `LoginForm.tsx`), and the submit branch
137
+ logic moved into a unit-tested `useLoginSubmit` hook.
138
+
139
+ ### Changed
140
+
141
+ - No runtime change. Splitting only; identical bundled output.
142
+
143
+ Completes the demo-credential feature for **BFF-fronted apps**, whose demo pair
144
+ arrives ASYNChronously from `GET /bff/config` rather than a module-level const (a
145
+ const would bake the credentials into a bundle handed to a customer to
146
+ self-deploy). Two gaps closed:
147
+
148
+ 1. `prefillOnMount` was inert for async credentials (1.11.0 bug).
149
+ 2. `useBffLoginConfig().config` dropped the `demo` field the BFF now serves.
150
+
151
+ Requires `@dloizides/auth-client` **>= 4.2.0** (which surfaces `demo` on
152
+ `BffLoginConfig`) — the peer floor is raised accordingly. Additive for existing
153
+ consumers: the new `LoginForm` prop is optional and the synchronous /
154
+ module-const path is byte-identical.
155
+
156
+ ### Fixed
157
+
158
+ - **`prefillOnMount` now honours credentials that arrive AFTER mount.** `useState`
159
+ reads its initial argument on the first render only, so a demo pair landing a
160
+ render later (every BFF app) was silently dropped and the form stayed empty
161
+ beside a populated hint. `useDemoCredentials` now seeds when the resolved pair
162
+ transitions to non-null (or to a genuinely different pair), gated by VALUE so a
163
+ react-query refetch handing back a fresh-but-equal object does not re-seed on a
164
+ loop.
165
+
166
+ ### Added
167
+
168
+ - **`config.demo` carries through `useBffLoginConfig`.** `BffLoginConfig` gains
169
+ `demo?: DemoCredentials | null`, so the intended wiring works end-to-end:
170
+ ```tsx
171
+ const { config } = useBffLoginConfig(bffAuthClient);
172
+ const demo = useDemoCredentials({ credentials: config.demo ?? undefined, prefillOnMount: true });
173
+ ```
174
+ - **`PrefillIntent`** enum + **`LoginForm` gains `prefillIntent`** — the "don't
175
+ fight the user" control. An `Opportunistic` re-seed (a late config fetch) is
176
+ DROPPED unless both fields are still empty, so it never overwrites what a real
177
+ account holder is typing while `/bff/config` is in flight; an `Explicit` re-seed
178
+ (the user tapped "use these credentials") always applies. `useDemoCredentials`
179
+ sets the right intent automatically — most apps never touch this prop.
180
+ - **`DemoCredentials` is now re-exported from `@dloizides/auth-client`** (its
181
+ canonical owner) rather than defined locally, so there is exactly ONE shape
182
+ across the two packages. Structurally identical to 1.11.0 — no consumer change.
183
+
184
+ ### "Don't fight the user" — what the hook can and cannot know
185
+
186
+ `useDemoCredentials` does not own the input fields (`<LoginForm>` does), so it
187
+ cannot observe keystrokes. The interaction test it CAN make is on the field
188
+ values: an opportunistic seed applies only when both fields are still empty
189
+ (pristine). This is intentionally conservative — a user who typed and then
190
+ cleared a field keeps control for the rest of the session (a dropped
191
+ opportunistic bump is not retried). An explicit user request is never subject to
192
+ this test.
193
+
194
+ ## 1.11.0 (2026-07-17)
195
+
196
+ Additive release adding **public demo-credential support** for products with an
197
+ open, published demo account (Zygos is the first). Purely additive — every 1.10.0
198
+ export is unchanged, and an app that imports none of the new exports renders
199
+ byte-identically: the three new `<LoginForm>` props are all optional and inert
200
+ when omitted.
201
+
202
+ It fills form fields and nothing else. **No auth bypass**: the demo login travels
203
+ the ordinary path (same BFF, same Keycloak, same session cookie, same roles), and
204
+ no credential ships inside this package — the host supplies the pair.
205
+
206
+ ### Added
207
+
208
+ - **`<DemoCredentialsHint>`** — a themed panel for the login card showing a
209
+ published demo username + password with a one-tap "use these credentials"
210
+ action. Takes the usual `AuthTheme` / `labels` / `testIdPrefix` props. Values
211
+ render as selectable monospace chips (an `l`/`1` in a published password must
212
+ not be a guess). Omit `onUse` for a read-only panel.
213
+
214
+ **Fails closed**: no credentials — or a half-configured pair (blank username,
215
+ blank password, or a field that is `undefined` at runtime because an env var
216
+ was never set) — renders `null`. Zero DOM. A misconfigured demo can never
217
+ publish an empty credentials panel.
218
+
219
+ - **`useDemoCredentials({ credentials?, prefillOnMount? })`** — the hint↔form
220
+ wiring, so no app hand-rolls it. Returns two prop bags to spread:
221
+ `hintProps` (`{ credentials, onUse }`) and `loginFormProps`
222
+ (`{ initialUsername, initialPassword, prefillNonce }`).
223
+
224
+ `prefillOnMount` (default `false`) makes the form arrive already filled, so
225
+ signing in as the demo user is a single tap on "Sign in".
226
+
227
+ - **`resolveDemoCredentials(credentials?)`** — the exported fail-closed gate the
228
+ above share: returns a trimmed pair, or `null` when there is no usable demo.
229
+
230
+ - **`DemoCredentials`** type and `DEFAULT_DEMO_CREDENTIALS_HINT_LABELS` /
231
+ `DemoCredentialsHintLabels` (all copy stays app-supplied, as everywhere else).
232
+
233
+ - **`<LoginForm>` gains `initialUsername` / `initialPassword` / `prefillNonce`.**
234
+ The `initial*` props **seed** the fields; they do not own them — every value
235
+ stays editable and clearable, so a real account holder can always wipe the demo
236
+ values and sign in with their own. `prefillNonce` is the re-seed signal:
237
+ changing it re-applies the seed over fields the user has edited or cleared.
238
+
239
+ The nonce is not ceremony. `useState` ignores its initial argument on
240
+ re-render, so a fill button wired to `initialUsername` alone would render a
241
+ form that stays stubbornly empty — the nonce is what makes "click the hint →
242
+ form fills" work at all. `useDemoCredentials` manages it for you.
243
+
244
+ ### Internal
245
+
246
+ - `useLoginPrefill` — extracts `<LoginForm>`'s credential field state (the only
247
+ part of that component with real logic), so the seed/re-seed contract is unit
248
+ tested directly rather than through the JSX.
249
+
250
+ ## 1.10.0 (2026-07-17)
251
+
252
+ Additive release adding the shared **logout sequencer**. Purely additive — every
253
+ 1.9.x export is unchanged, no component behaviour changes, and apps that do not
254
+ import `performBffLogout` are byte-identical.
255
+
256
+ ### Added
257
+
258
+ - **`performBffLogout({ client, onClearSession?, onRedirect?, onError?, navigateToIdp? })`**
259
+ — the shared logout sequence, extracted because all six consuming apps
260
+ hand-rolled it in their own `AuthProvider` and the orderings disagreed. Runs:
261
+ 1. `onClearSession()` — local state first, so the UI reflects the sign-out
262
+ immediately.
263
+ 2. `await client.logout({ redirect: false })` — lets the `POST /bff/logout`
264
+ SETTLE, and takes the IdP logout URL rather than letting the client navigate
265
+ mid-sequence.
266
+ 3. Navigates exactly once, last: to the IdP logout URL when the BFF returned
267
+ one (ending the IdP browser session), otherwise `onRedirect()`. When it
268
+ navigates to the IdP it deliberately does NOT also call `onRedirect` — two
269
+ competing navigations would race, and the IdP round-trip already returns the
270
+ user to the app.
271
+
272
+ Never rejects; always navigates, even when the BFF logout fails (a down BFF
273
+ must not trap the user on a page they asked to leave).
274
+ - **`BffLogoutClient`** / **`PerformBffLogoutOptions`** types.
275
+
276
+ ### Why
277
+
278
+ `zygos-web` and `agora-web` navigated FIRST and called the BFF second:
279
+
280
+ ```ts
281
+ redirectTo('/(auth)/login'); // → window.location.replace() → document unload
282
+ await bffAuthClient.logout(); // races the unload — may never be sent
283
+ ```
284
+
285
+ That is wrong twice over:
286
+
287
+ 1. **The BFF call can be cancelled.** A document navigation kills in-flight
288
+ fetches, so `POST /bff/logout` may never reach the server: the SPA looks
289
+ logged out while the httpOnly session cookie stays alive. The `catch` logged
290
+ it "non-fatal", so the defect was silent.
291
+ 2. **The IdP session survives.** `BffAuthClient.logout()` ends the identity
292
+ provider's browser session with a top-level navigation to its RP-initiated
293
+ logout URL (only a real navigation carries the IdP's own cookie — a
294
+ back-channel POST cannot clear it). An app that navigates to its own login
295
+ route first pre-empts that navigation, so the IdP still considers the user
296
+ signed in and the next authorize round-trip silently re-authenticates them —
297
+ the "sign-out that signs you back in" bug the client's own docs warn about.
298
+
299
+ Synthetic tests that stub the client never reproduce either failure (there is no
300
+ real navigation to cancel the request), which is why they survived. The helper
301
+ owns the sequence so neither can be reintroduced per-app.
302
+
303
+ ## 1.8.0 (2026-07-09)
304
+
305
+ Additive release for the Move 2b passkey in-app rebuild — the passkey ceremony
306
+ now runs **in-page** (`navigator.credentials.*` against the BFF's same-origin
307
+ `/bff/passkey/*` fetch endpoints) instead of a full-page redirect to Keycloak.
308
+ **Inert by default / no behaviour change until the BFF's WebAuthn RP is
309
+ configured:** when the RP is off the options endpoint returns 501 and the hook
310
+ transparently falls back to the legacy redirect. No breaking changes; every
311
+ 1.7.x export is unchanged and the legacy `startPasskeyLogin` /
312
+ `startPasskeyRegistration` navigation helpers stay exported (they ARE the
313
+ fallback).
314
+
315
+ ### Added
316
+
317
+ - **`usePasskeyCeremony({ mode, returnUrl, onSuccess })`** — the react-query-free
318
+ in-page ceremony hook: options fetch → `navigator.credentials.create/get` →
319
+ verify fetch. Returns `{ status, error, isSupported, start, reset }`. Falls
320
+ back to the legacy Keycloak redirect on a 501 (RP not configured), any non-2xx
321
+ options response, or an unsupported browser — so nothing regresses until the
322
+ owner flips `Bff:Passkey`. A cancelled/timed-out ceremony (`NotAllowedError` /
323
+ `AbortError`) surfaces as `'cancelled'`, anything else as `'failed'`.
324
+ - **`PasskeyCeremonyMode`** (`Login` / `Register`) and **`PasskeyCeremonyStatus`**
325
+ (`Idle` / `Running` / `Success` / `Error`) `const enum`s.
326
+
327
+ ### Changed
328
+
329
+ - **`<PasskeyLoginButton>`** and **`<PasskeySettingsCard>`** now drive
330
+ `usePasskeyCeremony` instead of `window.location.assign`. Same exported props,
331
+ same testIDs — a drop-in bump for consumers; the in-page path only activates
332
+ once the BFF RP is configured, otherwise they behave exactly as before.
333
+
334
+ ## 1.7.0 (2026-07-07)
335
+
336
+ Additive release for the Kefi multi-role overhaul — the shared machinery for a
337
+ cross-dashboard nav. No breaking changes; every 1.6.x export is unchanged.
338
+
339
+ ### Added
340
+
341
+ - **`resolveAccessibleRoutes(user, table)`** — the multi-role companion to
342
+ `resolvePostLoginRoute`. Returns the full, ordered list of `RoleRoute` entries
343
+ the user's role-set unlocks (not just the top one), reusing the same
344
+ `collectUserRoles` reader and `RoleRouteTable`. Table (priority) order is
345
+ preserved; the table `fallback` is never included; a role-less user gets `[]`.
346
+ Pure and app-agnostic — an app renders the result as a cross-dashboard nav.
347
+ - **`RoleRoute.labelKey?`** — optional translation key carried per route so a
348
+ consuming app can render a localized label per accessible dashboard. Ignored
349
+ by `resolvePostLoginRoute`; the package stays i18n-agnostic (carries the key,
350
+ the app resolves it).
351
+
352
+ ## 1.3.0 (2026-05-31)
353
+
354
+ Additive release for the auth-UX reuse roll-out (feature-reuse-roadmap #1).
355
+ Promotes the "Forgot password?" modal body the product apps each hand-rolled
356
+ into a single shared, **react-query-free** export so katalogos / erevna / kefi
357
+ stop duplicating it. No breaking changes; every 1.2.x export is unchanged.
358
+
359
+ ### Added
360
+
361
+ - **`<ForgotPasswordFields>`** — the embedded, themeable "request a reset link"
362
+ body, sized to live inside an app-owned modal shell (no screen/card wrapper,
363
+ no title — the host modal owns those). Adds the two modal affordances a
364
+ screen-shaped form lacks: a `Cancel` button beside submit and a `Close` button
365
+ on the success state, each rendered only when its handler is supplied. Clears
366
+ itself when `visible` flips to `false` so reopening starts fresh.
367
+ - **`useForgotPasswordSubmit`** — the headless, **react-query-free** forgot-
368
+ password logic: a plain `useState` + `client.forgotPassword(...)` promise
369
+ chain that runs on a provider-less login route (where the existing
370
+ `useBffForgotPassword` `useMutation` crashes with "No QueryClient set"). Email
371
+ validation gating via `canSubmit`, anti-enumeration success, `hasNetworkError`,
372
+ and `reset()`. Also exports the `isValidForgotPasswordEmail` helper.
373
+ - **`ForgotPasswordFieldsLabels`** + **`DEFAULT_FORGOT_PASSWORD_FIELDS_LABELS`** —
374
+ the forgot-password label bag plus the modal `cancel` / `close` strings.
375
+ - New `AuthTestIds` entries `forgotPasswordCancelButton` /
376
+ `forgotPasswordCloseButton`.
377
+
378
+ ### Notes
379
+
380
+ - The existing screen-shaped `<ForgotPasswordForm>` (react-query-based) is
381
+ unchanged. A future cleanup may re-base it on `useForgotPasswordSubmit` to drop
382
+ its react-query coupling; deferred to keep this release additive.
383
+
384
+ ## 1.2.0 (2026-05-22)
385
+
386
+ Additive release for Phase 3d of the unified-auth plan — the event-scoped PIN
387
+ UI. Adds the native, branded event-PIN login surface so an app (Kefi) can offer
388
+ "sign in with your event PIN" for operational staff (door / DJ / media) without
389
+ the user ever bouncing to Keycloak's hosted UI. No breaking changes; every
390
+ 1.1.0 export is unchanged.
391
+
392
+ ### Added
393
+
394
+ - **`<PinForm>`** — the ready-made, themeable single-step event-PIN login form,
395
+ built on `usePinLogin`. A PIN field + a "sign in" button; the
396
+ `eventExternalId` is a prop (the event context comes from the route/page, it
397
+ is never typed by the user). Styled by the same `AuthTheme` token bag as the
398
+ other forms; copy is supplied through the i18n-agnostic `PinFormLabels` bag
399
+ (English `DEFAULT_PIN_LABELS` fallback). `onSuccess` hands back the signed-in
400
+ `BffUser`.
401
+ - **`usePinLogin`** — the headless PIN hook (the custom-layout escape hatch). A
402
+ single-step flow: `submit(pin)` → `POST /bff/pin/login` → `BffUser`, with
403
+ `reset()`. Mirrors `useOtpLogin` (mounted-guard, memoised result,
404
+ latest-`eventExternalId` ref).
405
+ - **`PinFormLabels`** + **`DEFAULT_PIN_LABELS`** — the PIN label bag.
406
+ - New `AuthTestIds` entries for every PIN interactive element.
407
+ - Re-exports `BffPinLoginRequest` from `@dloizides/auth-client`.
408
+
409
+ ### Changed
410
+
411
+ - Peer dependency `@dloizides/auth-client` raised to `>=3.2.0` — the version
412
+ that adds `BffAuthClient.pinLogin`.
413
+
414
+ ## 1.1.0 (2026-05-22)
415
+
416
+ Additive release for Phase 2d of the unified-auth plan — the email-OTP UI. Adds
417
+ the native, branded email-OTP login surface so an app can offer "sign in with a
418
+ code" without the user ever bouncing to Keycloak's hosted UI. No breaking
419
+ changes; every 1.0.0 export is unchanged.
420
+
421
+ ### Added
422
+
423
+ - **`<OtpForm>`** — the ready-made, themeable two-step email-OTP login form,
424
+ built on `useOtpLogin`. Step 1 collects the email and sends a code; step 2
425
+ collects the code, verifies it, and offers "resend code" and "use a different
426
+ email". Styled by the same `AuthTheme` token bag as the other forms; copy is
427
+ supplied through the i18n-agnostic `OtpFormLabels` bag (English
428
+ `DEFAULT_OTP_LABELS` fallback). `onSuccess` hands back the signed-in `BffUser`.
429
+ - **`useOtpLogin`** — the headless OTP hook (the custom-layout escape hatch). A
430
+ small forward state machine over `OtpLoginStep`: `requestCode(identifier)` →
431
+ `EnterCode`, then `verifyCode(otp)` → `BffUser`, with `resend()` and
432
+ `reset()`. Mirrors `useBffAuth` (mounted-guard, memoised result).
433
+ - **`OtpLoginStep`** — the `RequestCode` / `EnterCode` step enum.
434
+ - **`OtpFormLabels`** + **`DEFAULT_OTP_LABELS`** — the OTP label bag.
435
+ - New `AuthTestIds` entries for every OTP interactive element.
436
+ - Re-exports `BffOtpRequestRequest`, `BffOtpVerifyRequest` and
437
+ `BffOtpRequestResult` from `@dloizides/auth-client`.
438
+
439
+ ### Changed
440
+
441
+ - Peer dependency `@dloizides/auth-client` raised to `>=3.1.0` — the version
442
+ that ships `BffAuthClient.requestOtp` / `.verifyOtp` (`POST /bff/otp/request`
443
+ and `POST /bff/otp/verify`).
444
+
445
+ ### Notes
446
+
447
+ - Still strictly thin: UI + the same-origin `/bff/*` client. The OTP client
448
+ methods carry the `X-BFF-Csrf` header like every other state-changing call;
449
+ no token handling, no secrets in the browser.
450
+ - This release adds the OTP surface to the package only. Wiring an app onto it
451
+ (the Kefi pilot) is Phase 3.
452
+
453
+ ## 1.0.0 (2026-05-22)
454
+
455
+ Initial release — the frontend half of the unified-auth plan (Phase 1b).
456
+
457
+ `@dloizides/auth-web` is the shared, themeable, branded auth UI for the
458
+ dloizides.com portfolio. It is extracted and generalised from the per-app auth
459
+ code that lived inside `apps/katalogos-web` so katalogos, erevna and kefi can
460
+ share one implementation instead of copy-pasting auth adapters.
461
+
462
+ ### Added
463
+
464
+ - **Themeable components**
465
+ - `<LoginForm>` — password login, built on `useBffAuth`.
466
+ - `<ForgotPasswordForm>` — request-a-reset-link, built on
467
+ `useBffForgotPassword`; no-enumeration confirmation state.
468
+ - `<ResetPasswordForm>` — choose-a-new-password, built on
469
+ `useResetPasswordForm`.
470
+ - All three are styled by an `AuthTheme` design-token bag so visually
471
+ distinct apps share one implementation.
472
+ - **Theming** — `AuthThemeProvider`, `useAuthTheme`, `defaultAuthTheme`, and
473
+ the `AuthTheme` token contract (colors / radii / spacing / typography).
474
+ Precedence: explicit `theme` prop → `<AuthThemeProvider>` context → default.
475
+ - **i18n-agnostic label bags** — `LoginFormLabels`, `ForgotPasswordFormLabels`,
476
+ `ResetPasswordFormLabels` plus English `DEFAULT_*` constants. The package
477
+ ships no `t()` / `FM()`; consuming apps pass already-localised strings.
478
+ - **Headless hooks** (the custom-layout escape hatch)
479
+ - `useBffAuth` — login / logout / `/bff/me` session, lifecycle status.
480
+ - `useBffForgotPassword`, `useBffResetPassword` — React Query mutations.
481
+ - `useResetPasswordForm` — reset-password form-state + validation logic.
482
+ - **`createBffAuthClient`** — one-line wiring of a same-origin `BffAuthClient`
483
+ with a lazily-resolved `fetch` (load-time side-effect-free).
484
+ - **Role-based post-login router** — `resolvePostLoginRoute(user, table)` and
485
+ `collectUserRoles`. The consuming app supplies the role → route table.
486
+ - **Password policy** — `validatePasswordPolicy`, `isPasswordValid`,
487
+ `PasswordPolicyError`; mirrors the TenantService backend rules.
488
+ - **Test IDs** — `AuthTestIds` + `withTestIdPrefix` for E2E targeting.
489
+ - Re-exports `BffAuthClient`, `createFetchHttpClient` and the BFF request /
490
+ response types from `@dloizides/auth-client` so consumers have a single
491
+ import surface.
492
+
493
+ ### Notes
494
+
495
+ - Strictly thin: UI + a same-origin `/bff/*` fetch client + the router helper.
496
+ **No secrets, no token handling, no Keycloak calls** — the per-app BFF owns
497
+ all of that server-side.
498
+ - Built on `@dloizides/auth-client` (peer dependency `>=3.0.0`), which ships
499
+ `BffAuthClient`.
500
+ - This release creates the package only. Porting `apps/katalogos-web` onto it
501
+ is Phase 1c.