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