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