@dloizides/auth-web 1.19.0 → 1.21.0

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