@dloizides/auth-web 1.17.0 → 1.18.1

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