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