@visns-studio/visns-components 6.6.4 → 6.15.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.
Files changed (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. package/src/index.js +233 -1
package/README.md CHANGED
@@ -9,6 +9,1034 @@ A comprehensive React component library used by the VISNS Studio team for CRM an
9
9
 
10
10
  VISNS Components is a React-based UI component library that provides a set of reusable, consistent, and customizable components for building web applications. It includes components for authentication, data grids, forms, navigation, and more, designed to work seamlessly together.
11
11
 
12
+ ## Recent Updates (v6.15.0)
13
+
14
+ ### The detail page joins the dashboard system
15
+
16
+ `GenericDetail`'s overview was a grid of tinted boxes — every single field in
17
+ its own bordered, filled container, so a summary of six facts arrived as six
18
+ competing panels and the card around them counted for nothing. The card is the
19
+ container now; the fields inside it are label-above-value pairs on the card's
20
+ own surface, on a real two-column grid (`--detail-overview-columns`) so the
21
+ right column's labels line up with the left's instead of drifting with whatever
22
+ the row above happened to need. Section headings take the page's ink at
23
+ 0.9rem/600, the eyebrow treatment they used to wear having moved down to the
24
+ field labels, and a field with nothing in it says so with a muted dash rather
25
+ than trailing off after its label.
26
+
27
+ A section can carry `meta` — the same item shape as `content` — rendered as
28
+ quiet facts at the far end of its heading, for the one or two things that
29
+ describe the whole card (a count, a last-updated stamp) rather than sitting
30
+ among the fields.
31
+
32
+ ```json
33
+ {
34
+ "title": "Template",
35
+ "meta": [{ "id": "checkpoint_count", "label": "Checkpoints" }],
36
+ "content": [ ... ]
37
+ }
38
+ ```
39
+
40
+ ### Tabs as a strip, for pages that want one
41
+
42
+ `page.tabLayout: "underline"` lays a detail page's tabs across the top with an
43
+ accent edge under the selected one, instead of down a rail on the left. The
44
+ rail costs a fifth of the page width, which is worth paying at seven tabs and
45
+ is not at two. It is the same treatment narrow viewports already gave any flat
46
+ rail, promoted out of the phone media query into a shared mixin and exposed as
47
+ `<TableFilter variant="underline">`. Every existing page keeps the rail.
48
+
49
+ ### Counts look like counts
50
+
51
+ An `arrayCount` column renders its number in a small neutral pill instead of as
52
+ a bare digit in a text column, and an empty list shows a muted dash instead of
53
+ an empty cell — an empty cell is what a column shows when it has no answer, and
54
+ here there is one. `arrayCountFrom` in `src/components/utils/displayValue.js`
55
+ also fixed what the count was: the old `value.length` returned the character
56
+ count for a JSON string that was never decoded, and nothing at all for a keyed
57
+ object. `sortable: false` now turns the header control off, for the JSON-backed
58
+ counts where server-side ordering is lexicographic nonsense.
59
+
60
+ ### Buttons on a detail page line up
61
+
62
+ The floating action bar's own `display: block; float: left` was overriding the
63
+ shared button mixin, so an icon and its label sat off-centre and the bar's
64
+ buttons were visibly shorter than every other button in the app. It is a flex
65
+ row of shared controls now, and Cancel — the way back out of editing, not a
66
+ destructive act — is a neutral secondary rather than error red beside the Save
67
+ it competes with. An overview form can name its own button with
68
+ `form.editLabel`.
69
+
70
+ ## Recent Updates (v6.14.0)
71
+
72
+ ### The vault list fits the window
73
+
74
+ `VaultManager` no longer asks for a fixed 25 entries. It measures the room
75
+ between the top of the list and the bottom of the window, subtracts the pager,
76
+ divides by the height of a row it has actually rendered, and asks the server
77
+ for exactly that many — so the page never scrolls and the pager is always in
78
+ view. Resizing refits after 150ms of quiet and keeps the reader on the page
79
+ that still holds the entry they were looking at.
80
+
81
+ The arithmetic is pure and tested: `fitRowsToHeight({available, pagerHeight,
82
+ rowHeight, chrome, min, max})` in `src/components/vault/vaultFit.js`, with
83
+ `pageForIndex` / `firstIndexOfPage` beside it. It clamps to `[5, 100]` — 100 is
84
+ `VaultController::MAX_PER_PAGE`, which silently clamps anything larger — and
85
+ returns `null` when it has nothing to measure, which is what stops the list
86
+ flashing a five-row page on first paint.
87
+
88
+ ```jsx
89
+ <VaultManager perPage={25} /> // opt out: a fixed size turns the fitting off
90
+ ```
91
+
92
+ ### One place for each vault action
93
+
94
+ Every row now ends in the same cluster: **copy username**, **copy password**,
95
+ **copy 2FA code** as icon buttons, a hairline, then Edit / Log / Delete with
96
+ their words intact. The copy-username glyph that used to sit inside the
97
+ username cell is gone — each action exists once. A copy that lands shows a tick
98
+ on the button for a second and a half rather than relying on a toast alone, and
99
+ the 2FA copy reports the server's own countdown ("2FA code copied · valid for
100
+ 14s"), so a code with three seconds left is visibly worth re-copying. The
101
+ ticking `OtpChip` stays in the 2FA column for anyone who wants to read the code
102
+ rather than paste it.
103
+
104
+ The fetch behind both is `fetchOtp(request, routes, entryId)` in
105
+ `src/components/vault/vaultOtp.js` — the request function is a parameter, so
106
+ the module carries no React and `node --test` drives it with a fake.
107
+
108
+ ### Checkboxes and radios the host cannot stretch
109
+
110
+ The vault's visibility radios rendered as 34×22 ovals inside the CRM, whose
111
+ untyped `input` rules set a width and a text-field height. Asking for the
112
+ native widget back was not enough, so `Vault.module.scss` now draws the control
113
+ itself: `appearance: none`, every axis pinned (`width`/`height`/`min`/`max` and
114
+ the flex basis), the radio's dot painted with an inset ring and the checkbox's
115
+ tick with a background image. The selector is element-qualified
116
+ (`input.checkControl`) so it outweighs a host rule of the form `.form input`.
117
+
118
+ ## Recent Updates (v6.13.0)
119
+
120
+ ### Desktop alerts
121
+
122
+ Native browser notifications while a tab of the app is open — a new task
123
+ assigned to you, or a text arriving on one of your lines — so the bell badge is
124
+ not the only thing that has to be watched.
125
+
126
+ No service worker and no push subscription. This is the plain `Notification`
127
+ API, which means the honest scope is *while a tab is open*: close the CRM and
128
+ the alerts stop. It also needs a secure context — `https://` or `localhost`;
129
+ over plain http the API is absent and every entry point below quietly no-ops.
130
+
131
+ **The module** — `src/components/notify/desktopNotifications.js`, exported as
132
+ `desktopNotifications` (and as the individual `desktopNotify`,
133
+ `desktopAlertsEnabled`, `desktopAlertsSupported`, `getDesktopPermission`,
134
+ `requestDesktopPermission`, `setDesktopAlertsEnabled`,
135
+ `DESKTOP_ALERTS_STORAGE_KEY`, `DESKTOP_ALERT_AUTO_CLOSE_MS`):
136
+
137
+ ```js
138
+ import { desktopNotifications } from '@visns-studio/visns-components';
139
+
140
+ desktopNotifications.notify({
141
+ title: 'New text from Margaret Chen',
142
+ body: 'Are we still on for Thursday?',
143
+ tag: 'sms-14', // one toast per subject; a burst replaces itself
144
+ url: '/sms?thread=14', // where a click lands
145
+ navigate, // react-router's navigate, when there is one
146
+ force: false, // show even if the tab is focused
147
+ });
148
+ ```
149
+
150
+ Four gates, all of which must open: the API exists, permission is `granted`,
151
+ the user's own switch is on, and **the tab is not already visible and focused**
152
+ — the in-app UI is showing the same thing, and an OS toast on top of it is
153
+ noise. A toast closes itself after 8 seconds; a click focuses the window,
154
+ closes the toast and then calls `onClick`, or `navigate(url)`, or
155
+ `location.assign(url)` in that order.
156
+
157
+ The switch lives in `localStorage` under **`visns.desktopAlerts`** (`'on'` /
158
+ `'off'`). Unset means *on once permission has been granted* — someone who has
159
+ just been through the browser prompt should not have to say yes twice.
160
+
161
+ **The bell** — `Notification` takes two new optional props:
162
+
163
+ | Prop | Type | Notes |
164
+ | --- | --- | --- |
165
+ | `echo` | Echo instance or `() => echo` | A factory is called inside the effect, so nothing connects for a consumer that passes none. |
166
+ | `channel` | `string \| null` | The user's private channel. `null` (the default) leaves the bell on polling. |
167
+
168
+ Laravel broadcasts these as `BroadcastNotificationCreated`, which is what
169
+ Echo's `.notification()` helper binds to; the payload is prepended to the list
170
+ in the same shape the poller returns, so a live item and a polled one are
171
+ interchangeable. Polling stays as the fallback — every 60s normally, dropping
172
+ to 5 minutes once a channel reports `pusher:subscription_succeeded`. A compact
173
+ **Desktop alerts** row at the foot of the dropdown owns the switch, asks for
174
+ permission on first enable, and reads *Blocked in browser settings* when the
175
+ browser has already refused.
176
+
177
+ The channel name is the consumer's to build, and should carry the environment
178
+ when one Pusher app serves several deployments — the same convention as
179
+ `sms-line.{id}.{env}`:
180
+
181
+ ```jsx
182
+ <Notification
183
+ setSystemAuth={setSystemAuth}
184
+ echo={getEcho}
185
+ channel={`user.${userProfile.id}.${import.meta.env.VITE_APP_ENV}`}
186
+ />
187
+ ```
188
+
189
+ Server-side that is `User::receivesBroadcastNotificationsOn()` plus a
190
+ `Broadcast::channel('user.{id}.' . config('app.env'), ...)` authorising the
191
+ owner. Pass `null` rather than guessing the suffix: the wrong one puts a
192
+ development browser on the production channel.
193
+
194
+ **Messages** — `SmsInboxBadge` raises the same alert on `sms.received` (never
195
+ on `sms.updated`), titled with the conversation's display name and carrying the
196
+ first 120 characters of the message.
197
+
198
+ ## Recent Updates (v6.12.0)
199
+
200
+ ### The application header, refined
201
+
202
+ `Navigation`'s `layout="full"` bar, restyled in `Navigation.module.scss` and
203
+ `GenericMain.module.scss`. No new markup, no new props, no behaviour change
204
+ beyond the two fixes noted at the end.
205
+
206
+ - **The bar** now ends somewhere: a 1px `rgba(255,255,255,.08)` hairline and a
207
+ soft two-stop shadow, instead of navy running straight into the page. Its
208
+ height is `--nav-logo-height` — the logo is capped to that figure and the bar
209
+ takes it as a minimum, so swapping a logo no longer resizes the shell.
210
+ - **Nav items** are 0.9rem/500 in a `--nav-item-radius` pill: `rgba(255,255,255,.08)`
211
+ on hover, `rgba(255,255,255,.14)` when current, plus a 2px
212
+ `--nav-active-bar-color` rule sitting on the bar's bottom edge (the items
213
+ stretch the full height of the bar so it can). The brand-red block the
214
+ current page used to wear is gone. Items with a sub-menu draw a caret, and
215
+ the sub-menu itself is a padded panel with rows that respond to the pointer —
216
+ its hover state was previously identical to its resting state, under an
217
+ `!important` that made it unreachable.
218
+ - **Action chips** sit in a quieter `rgba(255,255,255,.08)` tray as borderless
219
+ 34px circles, filling to `.14` on hover and taking the highlight colour on
220
+ the icon when active or open. A hairline sets the last chip (sign out) apart.
221
+ The second, contradictory copy of the chip rule — 32px, a 1px border in the
222
+ bar's own colour, a 1.05 hover scale — has been deleted; that invisible
223
+ border is what made the group read as a row of outlined squares.
224
+ - **Focus-visible rings** on nav links, sub-menu rows and chips: 2px in the
225
+ highlight colour, offset 2px. The bar suppressed outlines everywhere and put
226
+ nothing back.
227
+ - `.hactions-alternate` / `.nav-item-alternate` get the same treatment against
228
+ a light surface, so the alternate theme is the same design and not a second
229
+ one.
230
+ - Several declarations referenced `--radius-pill`, `--radius-sm`, `--speed`,
231
+ `--ease` and `--shadow-sm` with **no fallback**. An undefined custom property
232
+ with no fallback is invalid at computed-value time and the whole declaration
233
+ is discarded — which is why, in a project that had not declared those names,
234
+ the "pill" was a square and the bar had no shadow. Every one of them now
235
+ carries its default.
236
+
237
+ **New hooks** (all optional, all no-ops when unset — the documented block at the
238
+ top of `Navigation.module.scss` carries the full set):
239
+
240
+ | Hook | Default | What it moves |
241
+ | --- | --- | --- |
242
+ | `--nav-item-radius` | `var(--radius, 6px)` | The nav item's hover/active pill, and the sub-menu rows. |
243
+ | `--nav-active-bar-color` | `var(--highlight-color, #fff)` | The rule under the current page, and every focus ring. |
244
+ | `--nav-chip-radius` | `var(--radius-pill, 999px)` | One action chip. |
245
+
246
+ Changed defaults: `--nav-item-font-size` is `0.9rem` (was `var(--font-size-sm)`,
247
+ undefined in most projects and therefore nothing at all), `--nav-item-padding`
248
+ is `0.5rem 0.8rem`, `--nav-action-size` is `34px`.
249
+
250
+ **Two fixes that came with it**
251
+
252
+ - Active-item detection moved to `src/components/navActive.js` (`isNavActive`,
253
+ covered by `tests/navActive.test.mjs`). It used to branch on how many
254
+ segments the URL had, consult an item's children in only one of those
255
+ branches, and compare paths with `String.includes` — so `/tasks` matched
256
+ `/tasks-archive`, and on `/reports`, which the Admin item owns as a child,
257
+ nothing in the bar was marked at all. Now: a path matches when it is the
258
+ item's path or sits under it as a whole segment, and an item is active when
259
+ it matches itself or any child.
260
+ - Nav labels and sub-menu rows no longer set a native `title`, which drew the
261
+ browser's own tooltip on top of the menu it was describing. Every built-in
262
+ chip now carries both `data-tooltip-content` and `aria-label`, falling back
263
+ to a humanised id (`callRecordings` → "Call Recordings") when a config entry
264
+ brings no label.
265
+
266
+ ## Recent Updates (v6.11.0)
267
+
268
+ ### Messaging (SMS) — a virtual inbox on Zoom Phone lines
269
+
270
+ A new module under `src/components/sms`, plus two icons in `Navigation`'s
271
+ `SETTING_ICONS`. Nothing existing changes behaviour.
272
+
273
+ Zoom is **not connected yet**, and the UI is built around that rather than
274
+ around the day it is. `GET {base}/status` reports a transport of `zoom`, `log`
275
+ or `null`, and every surface here says in words which one it is looking at:
276
+ a message you send on `null` is saved, queued, and shown as *Held — not
277
+ connected* rather than as an error, because it is not one.
278
+
279
+ **Components**
280
+
281
+ | Export | What it is |
282
+ | --- | --- |
283
+ | `SmsInboxBadge` | The header popover. Mounts as an account action through Navigation's `renderers` slot. |
284
+ | `SmsInbox` | The page: line selector, search, filters, thread list, conversation, composer. |
285
+ | `SmsThreadPanel` | One conversation on its own — header, timeline, composer — for embedding on a client page. |
286
+ | `SmsComposeModal` | New message: line, a number or a client typeahead, templates, send. |
287
+ | `SmsLineSettings` | Settings → Messaging: status card, lines table, templates. |
288
+ | `useSmsLive` | Echo subscription per line, with a 30-second polling fallback when there is no Echo. |
289
+ | `makeSmsEndpoints`, `DEFAULT_SMS_ENDPOINTS`, `resolveSmsEndpoints` | The URL table. |
290
+ | `segmentCount`, `normaliseNumberForDisplay`, `looksLikeNumber`, `toE164`, `relativeTime`, `groupMessagesByDay`, `initialsFor`, `threadDisplayName`, `describeTransport`, `isHeldTransport`, `SMS_LIMITS`, `SMS_MAX_SEGMENTS`, `SMS_STATUS_LABELS` | Pure helpers, all covered by `tests/sms.test.mjs`. |
291
+
292
+ #### `SmsInboxBadge`
293
+
294
+ | Prop | Default | Notes |
295
+ | --- | --- | --- |
296
+ | `setting` | — | The navigation entry. Only `label` is read, for the tooltip and aria-label. |
297
+ | `userProfile` | — | Accepted so the component matches what Navigation's renderers hand over; unread. |
298
+ | `setSystemAuth` | — | Same. |
299
+ | `endpoints` | `DEFAULT_SMS_ENDPOINTS` | Partial objects are merged over the defaults. |
300
+ | `echo` | `null` | An Echo instance **or** a factory (`() => getEcho()`). A factory is called inside the effect, so a user without messaging never opens a Pusher connection. |
301
+ | `channelFor` | `` (id) => `sms-line.${id}` `` | Return the environment-suffixed name if the deployment shares a Pusher app. |
302
+ | `inboxUrl` | `'/sms'` | A row navigates to `` `${inboxUrl}?thread=<id>` ``. |
303
+ | `limit` | `8` | Sent as `per_page`. |
304
+
305
+ The trigger is the same 40px chip as `Notification` and `VaultQuickSearch`,
306
+ with the same badge, so three unread counts in the header read as one row.
307
+ ↑↓ move the highlight, Enter opens the conversation, Esc closes. The popover
308
+ carries `data-nav-overlay="open"` so `.hactions` stops clipping it.
309
+
310
+ Each row is a four-column grid — `3px | 32px | minmax(0, 1fr) | auto` — and
311
+ the unread marker lives in that fixed 3px track rather than in a
312
+ `border-left`, so an unread row and a read one are measured identically and
313
+ the avatars line up. The left pane of `SmsInbox` uses the same contract.
314
+ `initialsFor` understands the CRM's own name format: brackets and honorifics
315
+ are dropped and a comma is read as "Surname, First", so "Perera, Darshini
316
+ (Mrs)" is `DP` rather than `P(`. An unnamed number is `#` — two of its digits
317
+ in a circle read as data.
318
+
319
+ Both list modules (`Sms` and `Vault`) also reset `> li` explicitly and restate
320
+ their row geometry at `.rows > .row`. See "Navigation: the chip rules no
321
+ longer reach into overlays" below — the host's `li` rules are 0,1,2 and no
322
+ single module class can outrank them.
323
+
324
+ #### Layout tokens
325
+
326
+ | Token | Default | What it sizes |
327
+ | --- | --- | --- |
328
+ | `--s-inbox-offset` | `230px` | Subtracted from `100vh` for the host's own chrome, so the two panes scroll inside their card instead of the page scrolling under them. Set it on any ancestor if the CRM header is a different height. |
329
+ | `--s-control` | `34px` | One height for the search field, the line selector, Templates and Send, so a row of controls lines up on both edges. |
330
+ | `--s-select` | `primary @ 8%` | The selected thread row. |
331
+
332
+ #### `SmsInbox`
333
+
334
+ | Prop | Default | Notes |
335
+ | --- | --- | --- |
336
+ | `endpoints` | `DEFAULT_SMS_ENDPOINTS` | Merged over the defaults. |
337
+ | `userProfile` | `null` | Accepted for consistency; unread. |
338
+ | `canManage` | `false` | Shows the settings link and the "Simulate reply" tool. |
339
+ | `echo` | `null` | Instance or factory. **The page owns the subscription** and feeds the conversation pane, so the pane is mounted with `subscribe={false}`. |
340
+ | `channelFor` | `` (id) => `sms-line.${id}` `` | |
341
+ | `settingsUrl` | `'/settings/sms'` | |
342
+ | `clientUrl` | `` (id) => `/clients/${id}` `` | The "Open client" chip in the conversation header. |
343
+ | `pageTitle` | `'Messages'` | |
344
+
345
+ Deep links work: `?thread=<id>` opens that conversation, and selecting one
346
+ rewrites the query with `history.replaceState` rather than pushing a history
347
+ entry per click. Two panes above 900px; below it, one at a time with a back
348
+ button. Filters are All / Unread / Archived; the list pages with **Load more**.
349
+
350
+ #### `SmsThreadPanel`
351
+
352
+ | Prop | Default | Notes |
353
+ | --- | --- | --- |
354
+ | `threadId` | — | `null` renders the "pick a conversation" state. |
355
+ | `endpoints` | `DEFAULT_SMS_ENDPOINTS` | |
356
+ | `echo`, `channelFor` | `null` | Only used when `subscribe` is true. |
357
+ | `subscribe` | `true` | `false` when a parent already holds the subscription. |
358
+ | `liveEvent` | `null` | `{seq, payload}` from that parent. `seq` must change per event. |
359
+ | `refreshToken` | `0` | Changing it re-reads the conversation — how the inbox's poller reaches the pane. |
360
+ | `status` | `null` | The `/status` payload. Fetched here when not supplied. |
361
+ | `templates` | `null` | Fetched lazily on first opening the Templates menu when not supplied. |
362
+ | `lines` | `null` | `[{id, label}]`, only so a template's `{line}` has a label to fill with. `SmsInbox` passes its own list. |
363
+ | `canManage` | `false` | Gates "Simulate reply", which is also hidden once the transport is `zoom`. |
364
+ | `clientUrl` | `` (id) => `/clients/${id}` `` | |
365
+ | `onThreadChange`, `onMessage` | — | The inbox keeps its list in step through these. |
366
+ | `onBack` | `null` | Renders the back button (visible only below 900px). |
367
+ | `showTransportBanner` | `true` | The inbox sets `false` — it draws its own. |
368
+ | `compact` | `false` | A shorter composer, no transport banner sizing for a page. |
369
+
370
+ **Templates fill themselves in.** A body reading `Hi {first_name}, a reminder
371
+ of your review on {date} at {time}` is completed from the open conversation the
372
+ moment it is inserted — `{first_name}`, `{last_name}`, `{name}`/`{full_name}`,
373
+ `{date}`, `{time}`, `{number}` and `{line}`, case-insensitively, and `{{...}}`
374
+ and `{ ... }` as well. The names come from `thread.client` (which the backend's
375
+ `messaging.client_details` hook enriches with `first_name`, `last_name` and
376
+ `next_event`), falling back to the contact label and then to parsing the CRM's
377
+ filed `Surname, First (Title)` form; `{name}` is always the spoken order, never
378
+ the filed one. `{date}` and `{time}` come from `client.next_event.date` as
379
+ `Mon 24 Aug` and `2:30 pm`.
380
+
381
+ A token that cannot be filled is **left exactly as written**, and the composer
382
+ selects the first one after inserting: `your review on {date}` is visibly a
383
+ blank to type over, where dropping it would leave `your review on ` and be sent
384
+ that way. `fillTemplate(body, context)` and `nextPlaceholder(text, from)` are
385
+ exported from `smsHelpers` for anything else that wants the same rules.
386
+
387
+ Sending appends an optimistic bubble, then reconciles it against the 201
388
+ payload — matched on the temporary key it carries, so an `.sms.updated`
389
+ broadcast arriving first cannot produce a duplicate. Enter sends by default
390
+ (Shift+Enter for a newline); the toggle beside the counter persists in
391
+ `localStorage`. The counter is character count, segment count and encoding,
392
+ because "70" with no explanation looks like a bug: one curly quote or emoji
393
+ drops the message from GSM-7 to UCS-2 and the limit with it.
394
+
395
+ #### `SmsLineSettings`
396
+
397
+ | Prop | Default | Notes |
398
+ | --- | --- | --- |
399
+ | `endpoints` | `DEFAULT_SMS_ENDPOINTS` | |
400
+ | `userProfile` | `null` | Accepted for consistency; unread. |
401
+ | `staffOptions` | `[]` | **Required in practice** — `[{id, name}]`. `GET {base}/settings/lines` returns the staff already on each line but no route lists everyone who could be, and inventing one here would be a second contract. With nothing supplied the picker falls back to the union of staff already on lines. |
402
+ | `pageTitle` | `'Messaging'` | |
403
+
404
+ #### Endpoints
405
+
406
+ `makeSmsEndpoints(base = '/ajax/sms')` builds the table; every component takes
407
+ a partial `endpoints` object merged over it. Session + CSRF come from
408
+ `CustomFetch`, and GET is never cached.
409
+
410
+ ```
411
+ GET {base}/status -> {transport, connected, lines_count, unread_total}
412
+ GET {base}/lines -> {data: [{id, label, phone_number, display_number, active, unread_count}]}
413
+ GET {base}/unread -> {total, by_line, by_thread}
414
+ GET {base}/threads?line_id&search&unread_only&archived&page&per_page
415
+ POST {base}/threads {line_id, to, body?} -> 201 {thread, message|null}
416
+ GET {base}/threads/{id}?before&limit -> {thread, messages, has_more} (marks read)
417
+ PUT {base}/threads/{id} {client_id, client_name, contact_name}
418
+ POST {base}/threads/{id}/messages {body} -> 201 {message}
419
+ POST {base}/threads/{id}/read | /archive | /unarchive -> 204
420
+ POST {base}/threads/{id}/simulate-inbound {body} -> 201 {message} (manage, non-zoom only)
421
+ GET {base}/clients/search?q=
422
+ GET/POST/PUT/DELETE {base}/templates[/{id}]
423
+ GET/POST/PUT/DELETE {base}/settings/lines[/{id}]
424
+ ```
425
+
426
+ Broadcasts arrive on the private channel `sms-line.{lineId}` as `.sms.received`
427
+ and `.sms.updated`, both carrying `{thread, message}`.
428
+
429
+ #### Wiring it into a CRM
430
+
431
+ ```jsx
432
+ <Navigation
433
+ renderers={{
434
+ messages: (p) => (
435
+ <SmsInboxBadge
436
+ {...p}
437
+ echo={getEcho}
438
+ channelFor={(id) => `sms-line.${id}.${import.meta.env.VITE_PUSHER_ENV}`}
439
+ />
440
+ ),
441
+ }}
442
+ />
443
+ ```
444
+
445
+ ```json
446
+ {
447
+ "id": "messages",
448
+ "icon": "message",
449
+ "label": "Messages",
450
+ "permission": false,
451
+ "permissionKey": "Messaging Access"
452
+ }
453
+ ```
454
+
455
+ `message` (MessageSquare) and `messages` (MessagesSquare) were added to
456
+ `SETTING_ICONS`.
457
+
458
+ #### The fixture
459
+
460
+ `yarn dev:sms` serves `dev/sms-fixture.html` on port 5181 against an in-memory
461
+ backend (`dev/smsMockServer.js`): three lines, twelve conversations, mixed
462
+ statuses, one thread deep enough to page. The transport switch in the fixture
463
+ header is the point of it — on `null` a sent message stays *Held*, on `log` it
464
+ reaches *Sent*, on `zoom` it reaches *Delivered*. The mock also exports a fake
465
+ Echo, so "Simulate an inbound" pushes a real broadcast and the badge, the list
466
+ and the open conversation all move at once.
467
+
468
+ ## Recent Updates (v6.10.0)
469
+
470
+ ### Vault — a staff password manager
471
+
472
+ A new module under `src/components/vault`, plus the two small openings in
473
+ shared code it needed. Nothing existing changes behaviour.
474
+
475
+ **Components**
476
+
477
+ | Export | What it is |
478
+ | --- | --- |
479
+ | `VaultQuickSearch` | The header popover. Mounts as an account action through Navigation's new `renderers` slot. |
480
+ | `VaultManager` | The page: search, filter, create, edit, delete/restore, access log. |
481
+ | `VaultEntryForm` | The create/edit modal, usable on its own. |
482
+ | `PasswordGenerator` | The generator popover behind the form's "Generate" button. |
483
+ | `QrScanner` | The camera panel behind the form's "Scan QR" button. |
484
+ | `decodeImageData`, `decodeFromSource`, `decodeFromBlob` | The QR readers. `jsqr` stays behind a dynamic import inside them. |
485
+ | `OtpChip` | One entry's 2FA code, on demand, with a countdown ring. |
486
+ | `VaultAccessLog` | Per-entry and global access log. |
487
+ | `VaultConfirmPanel` | The "confirm your CRM password" form, driven by `useVaultReveal`. |
488
+ | `useVaultReveal` | The reveal + confirm + retry flow, shared by both surfaces. |
489
+ | `makeVaultEndpoints`, `DEFAULT_VAULT_ENDPOINTS`, `resolveVaultEndpoints` | The URL table. |
490
+ | `parseOtpAuthUri`, `generatePassword`, `scorePassword`, `formatOtp`, `secondsUntilBoundary`, `matchesShortcut`, `shortcutLabel`, `isValidBase32`, `cleanBase32`, `describeOtpConfig`, `hostFromUrl`, `openableUrl`, `parseTags`, `OTP_DEFAULTS` | Pure helpers, all covered by `tests/vault.test.mjs`. |
491
+
492
+ #### `VaultQuickSearch`
493
+
494
+ | Prop | Default | Notes |
495
+ | --- | --- | --- |
496
+ | `setting` | — | The navigation entry. Only `label` is read, for the tooltip and aria-label. |
497
+ | `userProfile` | — | Accepted so the component matches what Navigation's renderers hand over; unread. |
498
+ | `setSystemAuth` | — | Same. |
499
+ | `endpoints` | `DEFAULT_ENDPOINTS` | Partial objects are merged over the defaults. |
500
+ | `manageUrl` | `'/settings/vault'` | Where "Manage vault" and "Add a password" go. |
501
+ | `shortcut` | `['mod+k', 'mod+shift+k']` | A string or a list; any one of them opens the popover. `mod` is ⌘ on a Mac, Ctrl elsewhere. The first is what the footer and the trigger tooltip print. |
502
+ | `limit` | `8` | Sent as `per_page`. |
503
+
504
+ **⌘K** (Ctrl+K off a Mac) opens it from anywhere; ⌘⇧K still does too. The
505
+ listener is on `window` in the capture phase, because the CRM is full of
506
+ controls that stop keydown from propagating — the data grid, the rich text
507
+ editors, react-select — and a bubbling listener behind any of them never hears
508
+ the key. It stands down while a modal dialog is open and while the caret is in
509
+ rich text (a TinyMCE body is `contenteditable` in an iframe, and an editor's
510
+ own ⌘K has the better claim).
511
+
512
+ ↑↓ move the highlight, Enter reveals and copies the highlighted row's
513
+ password, Esc closes. Hovering a row highlights it. With a row highlighted,
514
+ `Alt+U` copies its username and `Alt+O` opens its 2FA code — Alt-modified
515
+ because focus never leaves the search box, so a bare letter is a character
516
+ being typed into the query. The footer lists both.
517
+
518
+ The popover is a 560px opaque panel with its own border and shadow, headed by
519
+ a permanent primary **Open password manager** button — the manager is the only
520
+ place entries can be added, edited or deleted, so it is never more than one
521
+ click away.
522
+
523
+ Each result is **one line**: a title, then `· username · host` in muted text,
524
+ truncated with an ellipsis (the whole `title — username — url` is in the row's
525
+ `title` attribute), and on the right a cluster of 29px icon buttons that never
526
+ wraps — copy username, copy password, the 2FA chip, open site. Each carries a
527
+ tooltip and an aria-label. They were labelled pills, and with a real title
528
+ beside a real email username they spilled onto two and three rows per entry.
529
+ The 2FA chip is the one that grows: idle it is the same square button, and once
530
+ a code is fetched it expands in place to the digits and countdown ring.
531
+
532
+ There is no "show the password" here. A revealed password goes to the
533
+ clipboard and nowhere else — this popover hangs off the header of a screen
534
+ other people walk past, and a password printed into a list is one anyone behind
535
+ the desk can read. Nothing in a row ever adds a second line. (The only time a
536
+ password is put on screen is when the browser refuses the clipboard outright,
537
+ and then it goes in the panel's own fallback field, labelled and dismissable.)
538
+ `VaultManager` keeps its own reveal, which is a deliberate page-level action.
539
+
540
+ #### `VaultManager`
541
+
542
+ | Prop | Default | Notes |
543
+ | --- | --- | --- |
544
+ | `endpoints` | `DEFAULT_ENDPOINTS` | As above. |
545
+ | `userProfile` | — | Accepted for symmetry with the library's other pages; the server decides what a user may see, so nothing here reads it. |
546
+ | `canManage` | `false` | Gates "Show deleted", the global access log, the per-entry log button, and whether an entry may be shared with the team. |
547
+ | `pageTitle` | `'Password manager'` | |
548
+
549
+ It reads `?create=1&title=…` from the URL on mount and opens the form
550
+ prefilled — that is the link the quick search's empty state produces.
551
+
552
+ A heading and a primary "New entry" button, then one toolbar holding search,
553
+ the visibility filter, "Show deleted" and the global access log, then the
554
+ table: title (with the host beneath it), username with a copy button, URL,
555
+ visibility pill, 2FA, updated, and labelled row actions — Copy password, Edit,
556
+ Log, Delete, or Restore on a deleted row. Below 1200px the URL column folds
557
+ away and the host under the title carries it instead, so an iPad at 1024px
558
+ never scrolls sideways.
559
+
560
+ #### The endpoints object
561
+
562
+ ```js
563
+ import { makeVaultEndpoints } from '@visns-studio/visns-components';
564
+
565
+ const endpoints = makeVaultEndpoints('/ajax/vault'); // the default
566
+ ```
567
+
568
+ ```js
569
+ {
570
+ list: base, // GET ?search=&page=&per_page=&sort=&direction=&include_deleted=1
571
+ show: id => `${base}/${id}`, // GET
572
+ create: base, // POST
573
+ update: id => `${base}/${id}`, // PUT
574
+ destroy: id => `${base}/${id}`, // DELETE -> 204
575
+ restore: id => `${base}/${id}/restore`,
576
+ confirm: `${base}/confirm-password`, // POST {password} -> 204 | 422 | 429
577
+ reveal: id => `${base}/${id}/reveal`,// POST -> {password} | 423
578
+ otp: id => `${base}/${id}/otp`, // GET -> {code, expires_in, period}
579
+ logCopy: id => `${base}/${id}/log`, // POST {action}
580
+ entryLog: id => `${base}/${id}/log`, // GET
581
+ globalLog: `${base}/log`, // GET ?user_id=&action=
582
+ }
583
+ ```
584
+
585
+ Pass a partial `endpoints` prop to move one route; the rest keep their
586
+ defaults.
587
+
588
+ ##### The password confirmation is the server's decision, not the UI's
589
+
590
+ `reveal` may answer `423` — "confirm your CRM password first" — and
591
+ `useVaultReveal` treats that as a step rather than a failure: it parks the
592
+ caller's promise, both surfaces draw `VaultConfirmPanel`, and a successful
593
+ confirmation retries the original reveal and settles the promise the caller is
594
+ already awaiting.
595
+
596
+ Whether that happens at all is the backend's call —
597
+ `vault.require_password_confirmation` in `visns-packages`, which the CRM
598
+ currently has **off**, so a reveal answers `200` and no panel is ever drawn.
599
+ Nothing in the UI assumes otherwise: every confirm-related element is behind an
600
+ actual `423`, and no copy anywhere promises an unlock window. Leave the
601
+ handshake wired up regardless — it is the contract the moment a consumer turns
602
+ the flag on.
603
+
604
+ On `PUT`, **omit** `password` / `totp_secret` to leave them unchanged, send
605
+ `""` to clear, send a value to replace. The form does this by tracking intent
606
+ rather than value — neither the list nor the show payload carries a secret, so
607
+ both fields necessarily start blank even when editing an entry that has them.
608
+
609
+ `totp_secret` accepts a bare base32 secret or a full `otpauth://totp/…` URI.
610
+ The field says what it understood — `6 digits · 30 s · SHA1` — before you save.
611
+
612
+ ##### Getting the secret in: four ways, one field
613
+
614
+ The QR code is the one thing every provider shows; the link underneath it is
615
+ not. So the 2FA field takes the code itself as well:
616
+
617
+ | Way | How |
618
+ | --- | --- |
619
+ | Type or paste | The `otpauth://` link, or the bare base32 key. |
620
+ | **Scan QR** | Opens the device camera in a panel inside the form. Hidden entirely where `navigator.mediaDevices.getUserMedia` does not exist. |
621
+ | **Upload QR image** | A file picker (`accept="image/*"`) — a screenshot, or the PNG a provider offers. |
622
+ | Paste or drop an image | Paste a screenshot straight into the field (⌘/Ctrl+V with an image on the clipboard), or drag the image file onto it. |
623
+
624
+ All four end as decoded text in the same input, so the same feedback line
625
+ vouches for it and the API sees no difference. A QR that is not an
626
+ authenticator code says so and quotes what it did contain; an image with no
627
+ code in it says *"No QR code found in that image — try a sharper screenshot, or
628
+ paste the setup link instead."*
629
+
630
+ **The decoder is lazy.** `jsqr` (~131 KB raw, ~47 KB gzipped) is behind
631
+ `await import('jsqr')` inside `qrDecode.js` and is fetched only when a scan,
632
+ upload, paste or drop actually starts — a consumer that never opens the vault
633
+ form never downloads it. Where the browser has the platform's own
634
+ `BarcodeDetector` (Chrome and Edge, desktop and Android) that is tried first
635
+ and jsQR may never load at all. Images are downscaled to 1280px on the long
636
+ edge before decoding, and a second pass tries the inverted reading for
637
+ dark-mode screenshots.
638
+
639
+ **The camera needs https.** `getUserMedia` is unavailable on an insecure
640
+ origin — `localhost` excepted — and the panel says so rather than failing
641
+ silently. It also names the difference between a blocked permission, no camera
642
+ at all, and a camera that would not start, because each needs the user to do
643
+ something different. Every exit from the panel stops the stream.
644
+
645
+ **On an iPad:** the camera works in Safari on an https page, and the rear
646
+ camera is requested (`facingMode: environment`); "Switch camera" appears when
647
+ more than one video input is listed. Safari has no `BarcodeDetector`, so iPad
648
+ always uses jsQR — decoding is a little slower there but still well inside the
649
+ 250ms frame budget the scanner polls on. Safari also requires the video be
650
+ muted and inline, which the panel sets; and it will not grant a camera inside
651
+ an iframe without `allow="camera"` on that iframe.
652
+
653
+ #### Checkboxes and radios keep their own box
654
+
655
+ The host CRM styles bare `input` — padding, a min-width, a text-field height
656
+ and a pill radius — and none of that is typed, so it lands on
657
+ `input[type=radio]` too: the form's Visibility choice rendered as two wide
658
+ ovals with a dot adrift inside one of them, and the manager's "Show deleted"
659
+ was the same shape. The module's `vault-reset` cannot help, because it unsets
660
+ `appearance` and for these two that is what draws the control at all.
661
+
662
+ Every checkbox and radio the vault renders now carries `styles.checkControl`,
663
+ which restates the native box — 16px square, no padding, no border, no radius
664
+ (50% for a radio), `appearance: auto`, `accent-color: var(--v-primary)` — and
665
+ keeps the focus ring. Their labels are `align-items: center` flex rows with a
666
+ `0.5rem` gap. `tests/vault.test.mjs` fails if a new one is added without the
667
+ class.
668
+
669
+ #### Wiring it into an app
670
+
671
+ ```jsx
672
+ import { Navigation, VaultQuickSearch } from '@visns-studio/visns-components';
673
+
674
+ <Navigation
675
+ {...props}
676
+ renderers={{
677
+ vault: (p) => <VaultQuickSearch {...p} manageUrl="/settings/vault" />,
678
+ }}
679
+ />
680
+ ```
681
+
682
+ ```json
683
+ {
684
+ "id": "vault",
685
+ "icon": "key",
686
+ "label": "Vault",
687
+ "permission": false,
688
+ "permissionKey": "Vault Access"
689
+ }
690
+ ```
691
+
692
+ Through `GenericMain` the same object is passed as `navRenderers`:
693
+
694
+ ```jsx
695
+ <GenericMain
696
+ {...props}
697
+ navRenderers={{ vault: (p) => <VaultQuickSearch {...p} /> }}
698
+ />
699
+ ```
700
+
701
+ And the page itself, on a route:
702
+
703
+ ```jsx
704
+ <VaultManager canManage={userProfile.permissions.includes('Vault Manage')} />
705
+ ```
706
+
707
+ #### The fixture
708
+
709
+ ```bash
710
+ yarn dev:vault # http://localhost:5180/vault-fixture.html
711
+ ```
712
+
713
+ The page carries a **QR demo**: a real setup QR for `ACME Co /
714
+ jane@example.com`, and a button that hands it to an open entry form the way the
715
+ clipboard would. It is how the decode path gets reviewed on a desktop with no
716
+ camera attached — dragging the same image onto the field, or saving it and
717
+ using "Upload QR image", takes the identical path.
718
+
719
+ `dev/vaultMockServer.js` answers every route from memory and a real ticking
720
+ TOTP. The header carries two switches: `canManage`, and **require password
721
+ confirmation** — off by default, as the CRM now runs it, so a reveal answers
722
+ straight away; ticked, it puts the `423 → confirm → retry` handshake back (the
723
+ demo password is `password`, and the window it buys is 90 seconds so the 423
724
+ path comes back). It is never imported by the library and is not published — `files` ships
725
+ `src` and `README.md` only.
726
+
727
+ The fixture's fake header reproduces the one thing about the CRM's chrome that
728
+ matters here: `.hactions` is a horizontal scroll container, so it clips on both
729
+ axes, and a popover anchored inside it is cut off unless the host is told to
730
+ let it out. That is what `data-nav-overlay="open"` on the popover wrapper does
731
+ — the same contract `Notification` uses.
732
+
733
+ ### Navigation: `renderers`
734
+
735
+ `Navigation` gained an optional `renderers` prop — `{ [setting.id]: ({ setting,
736
+ userProfile, setSystemAuth }) => node }`. A matching id renders the supplied
737
+ node inside the same row wrapper the built-in branches use, on both the header
738
+ chips and the sidebar rail, and is checked before the hard-coded chain so a
739
+ project can also override a shipped action. Empty by default. `GenericMain`
740
+ passes it through as `navRenderers`. `key` and `lock` were added to
741
+ `SETTING_ICONS`.
742
+
743
+ ### Navigation: the chip rules no longer reach into overlays (extended in 6.11.0)
744
+
745
+ **6.11.0** found the same shape again in the rules the first pass did not
746
+ cover. `.hactions > ul li` and `.hactions > ul li > span` were descendant
747
+ selectors at 0,1,2, so a popover that mounts a **list** — the vault's results,
748
+ the messaging inbox's threads — had every row inheriting `display: flex`,
749
+ `justify-content: center`, a 32px minimum box and 2px side margins. A grid row
750
+ was quietly flattened into a centred flex line and the whole list sat indented;
751
+ the `li > span` rule turned the text cell into a flex box, which is what
752
+ stopped the ellipsis engaging on a long preview.
753
+
754
+ | Was | Now |
755
+ | --- | --- |
756
+ | `.hactions > ul li` | `.hactions > ul > li:not([data-nav-overlay] *)` |
757
+ | `.hactions > ul li > div, li > span` | `.hactions > ul > li > div:not(…), > li > span:not(…)` |
758
+ | `.hactions > ul li button:not(…)` | `.hactions > ul > li button:not(…)` |
759
+ | `.hactions-alternate > ul li` | `.hactions-alternate > ul > li:not([data-nav-overlay] *)` |
760
+
761
+ The chips are always direct children of that `ul`, so `> li` loses nothing and
762
+ cannot reach into anything a chip opens; the `:not()` is belt and braces for a
763
+ project that nests one. `tests/vault.test.mjs` guards every element name the
764
+ sheet can use — `a`, `button`, `li`, `ul`, `span`, `svg`, `img`, `div`, `p`,
765
+ `input` — not just the two that were caught the first time.
766
+
767
+
768
+
769
+ `.hactions > ul` styles the header's account chips, and it did so with bare
770
+ `a` / `button` descendant selectors. Anything mounted inside that cluster
771
+ inherited white text, a fixed 36px box and a pill radius, at a specificity no
772
+ popover's own module class could beat — `Notification` only survived it by
773
+ carrying `!important` overrides. The vault's popover rendered as blank white
774
+ boxes on a white panel.
775
+
776
+ Five selectors now exclude `[data-nav-overlay] *`, the same flag the `:has()`
777
+ overflow rule beside them already relies on:
778
+
779
+ | File line | Was | Now |
780
+ | --- | --- | --- |
781
+ | `.hactions > ul` | `a, button` | `a:not([data-nav-overlay] *), button:not([data-nav-overlay] *)` |
782
+ | `.hactions > ul` | `li button` | `li button:not([data-nav-overlay] *)` |
783
+ | `.hactions > ul` | `button` | `button:not([data-nav-overlay] *)` |
784
+ | `.hactions-alternate > ul` | `a` | `a:not([data-nav-overlay] *)` |
785
+ | `.hactions-alternate > ul` | `button` | `button:not([data-nav-overlay] *)` |
786
+
787
+ It is a pure exclusion — everything that matched before still matches unless it
788
+ sits inside an open overlay — so the chips are pixel-identical and the
789
+ notification bell and account switcher are untouched (their triggers are
790
+ siblings of their overlays, never inside them). `tests/vault.test.mjs` compiles
791
+ the sheet and fails if any `.hactions` rule reaches a descendant control again.
792
+
793
+ ### CustomFetch: `options.silentStatuses`
794
+
795
+ ```js
796
+ CustomFetch(url, 'POST', body, onSuccess, null, { silentStatuses: [423] })
797
+ .catch((error) => {
798
+ if (error.status === 423) { /* error.data is the parsed body */ }
799
+ });
800
+ ```
801
+
802
+ A listed status skips both the toast and the `errorCallback`, and rejects with
803
+ `status` and the parsed `data` hung directly off the error. Previously an
804
+ `errorCallback` only ever saw a flattened message string, so there was no way
805
+ to branch on a status that is part of a flow rather than a failure — which is
806
+ exactly what the vault's 423 is. Opt-in, so every existing caller is unchanged.
807
+
808
+ ## Recent Updates (v6.9.0)
809
+
810
+ ### React 19 support
811
+
812
+ `peerDependencies` now accept `^17 || ^18 || ^19` for `react` and `react-dom`.
813
+ Widened only after an audit and a real render against React 19.2.8.
814
+
815
+ **Fixed** (genuine React 19 removals):
816
+
817
+ - **12 `defaultProps` on function components**, across `GroupedReportRenderer`
818
+ and the nine `reportSemanticSteps/*` files. React 19 removed `defaultProps`
819
+ for function components, so each of those props would silently have become
820
+ `undefined`. Converted to default parameters — same values, and the change is
821
+ invisible under React 17/18.
822
+ - **Two callback refs with expression bodies** in `SketchField`
823
+ (`ref={c => (this._x = c)}`). React 19 treats a callback ref's return value
824
+ as a cleanup function, so those handed React an element where it expects a
825
+ function. Converted to block bodies.
826
+ - **`prop-types` was undeclared.** 183 lines import it and it resolved only
827
+ because a transitive dependency hoisted it — a latent packaging bug that
828
+ would bite any consumer on pnpm or Yarn PnP, React version notwithstanding.
829
+ Now a real dependency.
830
+
831
+ **Warns but works under 19:** `propTypes` on function components is ignored
832
+ rather than removed, so the ~180 declarations still present are dead weight and
833
+ nothing more — no warning, no error. They are kept because they still document
834
+ intent, and still run under 17/18.
835
+
836
+ **Verified:** the existing suite stays green under React 18 (152 tests), and
837
+ every auth screen, both `ClientAuth` layouts, `CallQueuePop`, `CallQueueSettings`,
838
+ `GroupedReportRenderer` and `SemanticValueInput` server-render against React
839
+ 19.2.8 with **no `console.error` or `console.warn` from any component**.
840
+
841
+ #### Known limits under React 19
842
+
843
+ Seven of this library's own dependencies declare peer ranges that exclude
844
+ React 19: `@nivo/{bar,core,line,pie}`, `react-quill`, `react-sortable-hoc` and
845
+ `react-toggle`. Installing the package root against React 19 will therefore
846
+ produce peer warnings (and an install error under strict npm).
847
+
848
+ **None of them is reachable from the auth or call-pop entry points.** Verified
849
+ by tracing the import graph: `ClientAuth`, `ImpersonateGate`, `LogoutScreen`,
850
+ `Login`, `TwoFactorAuth` and `CallQueuePop` pull in none of the seven. A React
851
+ 19 consumer should import those by path —
852
+
853
+ ```js
854
+ import ClientAuth from '@visns-studio/visns-components/src/components/auth/ClientAuth';
855
+ ```
856
+
857
+ — which is what the Next.js portal already does for `ImpersonateGate`. Importing
858
+ the package root pulls in every component and therefore all seven.
859
+
860
+ `CallQueueSettings` is the one auth-adjacent component that does reach a
861
+ blocked dependency (`react-toggle`), and `DataGrid` depends on the vendored
862
+ `@visns-studio/visns-datagrid-*` fork, which has its own React 19 work
863
+ outstanding. Neither is a blocker for the portal, which uses neither.
864
+
865
+ ## Recent Updates (v6.8.3)
866
+
867
+ ### A two-pane layout for the client sign-in
868
+
869
+ The client screens rendered a small card floating in whatever vertical space
870
+ the host page gave them, which on the portal meant a login box marooned in a
871
+ tall empty band. `layout="split"` brings across the staff sign-in's design
872
+ language — the brand on one side, the task on the other — as an **opt-in**:
873
+ `layout="card"` is the default and is byte-identical to what every consumer
874
+ rendered before.
875
+
876
+ - **The brand panel is now written once.** `_authBrandPanel.scss` and
877
+ `AuthBrandPanel.jsx` are shared by the staff `Login`, `AuthShell` (behind
878
+ TwoFactorAuth/Reset/Verify) and the new split layout. It had been copied
879
+ twice, which is the arrangement where one copy quietly stops matching the
880
+ others. The extraction is verified byte-for-byte: every CSS rule in
881
+ `Login.module.scss`, `TwoFactorAuth.module.scss`, `Reset.module.scss` and
882
+ `Verify.module.scss` is unchanged.
883
+ - **It is a section, not a screen.** The staff `.auth` is `position: fixed;
884
+ inset: 0` because it owns the viewport. The split has no `100vh` anywhere: it
885
+ fills its parent (`height: 100%`, with `flex: 1` and `align-self: stretch` so
886
+ it works in a flex column too), because the consumer keeps its own header and
887
+ footer and hands this the space between.
888
+ - **Under 900px the brand becomes a header band**, not nothing. The staff
889
+ screens hide the photograph on a phone — the form is the whole job there. A
890
+ public-facing portal still has to say whose front door it is, so the panel
891
+ collapses to a compact strip carrying the image and the name, with the
892
+ eyebrow and tagline dropped.
893
+
894
+ Same tokens as the staff panel throughout (`--primary-color-darker`,
895
+ `--accent-color`, `--auth-font-family`, …) with the library's existing
896
+ fallbacks, so one token layer themes the whole product.
897
+
898
+ ## Recent Updates (v6.8.2)
899
+
900
+ ### CSRF resync on a no-reload hand-off
901
+
902
+ Paired with a `visns-packages` change. Every sign-in used to end in a full page
903
+ load, which fetched a new document and with it a new
904
+ `<meta name="csrf-token">`. The 6.8.1 curtain hand-off keeps the document — so
905
+ when the backend rotated the token on a successful 2FA challenge, the SPA held
906
+ a token the server had just invalidated and every subsequent POST came back
907
+ 419, as a toast storm.
908
+
909
+ The package is removing that rotation and adding a `csrf_token` field to its
910
+ auth success responses. The screens now consume it: **whenever an auth response
911
+ carries a non-empty `csrf_token`, the meta tag is updated before any callback,
912
+ state change or navigation** — `CustomFetch` re-reads the tag on every request,
913
+ so that is the whole of the fix.
914
+
915
+ Applied at every point where an auth response is consumed and the app then
916
+ continues without a reload: `Login` (both the plain success and the
917
+ `requires_two_factor` branch), `TwoFactorAuth` (challenge completion),
918
+ `ClientOTPVerify` and `ImpersonateGate`.
919
+
920
+ Fully back-compatible: `syncCsrfToken` is inert when the field is absent, when
921
+ the meta tag is missing, and when there is no `document` at all — so a backend
922
+ that never sends the field sees no change. Exported, along with
923
+ `syncCsrfFromResponse`, for consumers with their own auth flows.
924
+
925
+ ## Recent Updates (v6.8.1)
926
+
927
+ ### Pending states across the auth flow
928
+
929
+ Live review: pressing **Verify** on the 2FA screen showed nothing — the label
930
+ snapped back to "Verify", the screen sat there looking idle, and half a second
931
+ later the browser reloaded out from under it.
932
+
933
+ - **The 2FA success transition is no longer a hard reload.** It sets the auth
934
+ state and lets `GenericAuth` do the navigation, exactly as `Login`'s non-2FA
935
+ branch does, so the please-wait curtain covers the whole hand-off. The old
936
+ `window.location.href` bypassed that curtain entirely — the known 6.5.5 gap.
937
+ A `previousUrl` (or a consumer-set `successPath`) still gets a real
938
+ navigation, now under a full-screen curtain held from the click until the
939
+ browser unloads.
940
+ - **`GenericAuth` recognises `/2fa`** as a hand-off screen alongside `/login`,
941
+ which is what lets it perform that navigation. Previously it only ever moved
942
+ the user off `/login`, because the 2FA screen reloaded instead of asking.
943
+ - **Every control in the flow now has a pending state and is disabled while
944
+ pending**, so a double-submit is impossible: both 2FA buttons, the resend
945
+ link (which said "Resend Code" *while sending* — the in-flight moment itself
946
+ was silent), the Microsoft SSO button (which gave no sign at all that it had
947
+ been pressed), and both password-reset submits.
948
+ - **`AuthLoading` is exported.** It moved out of `GenericAuth` so the screens
949
+ raise the identical curtain — when a screen's own curtain and `GenericAuth`'s
950
+ are both up, there is nothing to see between them.
951
+
952
+ Visible to existing consumers only as a spinner and a disabled button where
953
+ there were neither. Two things change beyond that, both deliberate: the 2FA
954
+ success path no longer reloads the document, and `Login` gained a `copy` prop
955
+ whose defaults are the strings it already rendered.
956
+
957
+ ## Recent Updates (v6.8.0)
958
+
959
+ ### Portal adoption: wire protocols, injected navigation, bundler portability
960
+
961
+ 6.7.0 presented `ClientLogin` / `ClientOTPVerify` as portal parity, but the
962
+ Next.js portal could not adopt them: they spoke a different wire protocol from
963
+ the one behind `/api/auth/*` and nothing bridged the gap. Four blockers, any
964
+ one of them fatal, all fixed here:
965
+
966
+ - **The wire protocol is a prop.** `protocol="contact"` switches the request
967
+ bodies, the success test and the challenge-id requirement to ThroughLife's
968
+ contract. `'uuid'` remains the default and is bit-identical to 6.7.0. See
969
+ [The `protocol` prop](#the-protocol-prop).
970
+ - **The token reaches the consumer.** `onAuthenticated(user, token,
971
+ rawResponse)` on the verify step — `login-otp`'s `access_token` used to be
972
+ read and discarded, so there was nothing to write into a `portal_token`
973
+ cookie. When it is present the component defers all state and navigation to
974
+ the consumer.
975
+ - **react-router is optional.** Navigation is injectable (`onNavigate`,
976
+ `onChallengeIssued`, `onBack`, `challenge`) and the router hooks are consulted
977
+ only when nothing else was supplied and only when a Router exists. All three
978
+ client screens now mount cleanly with no router at all.
979
+ - **`ClientAuth`** puts both steps on one page, which is the shape a Next.js
980
+ App Router page can actually host — and makes the whole integration props and
981
+ config, with no adapter code.
982
+ - **Bundler portability.** `import.meta.env` reads moved behind `readBuildEnv`;
983
+ see below.
984
+
985
+ ## Recent Updates (v6.7.0)
986
+
987
+ ### Auth journey, client portal parity, and the call queue
988
+
989
+ Everything in this release is **opt-in**. Every new prop's default reproduces
990
+ 6.6.3's rendering and network behaviour exactly, because other applications
991
+ consume these components.
992
+
993
+ - **Configurable auth endpoints** — every URL the auth screens talked to was a
994
+ string literal inside the component that used it. They are now one `endpoints`
995
+ object, threaded from `GenericAuth` into each screen, whose defaults are the
996
+ old literals. See [the endpoints table](#the-endpoints-object).
997
+ - **One design language for the whole sign-in journey** — `TwoFactorAuth`,
998
+ `Reset` and `Verify` were still the pre-6.1 centred card, so signing in with
999
+ 2FA on crossed a visible design seam halfway through. All three now render the
1000
+ two-pane frame the login screen was redesigned onto, via the new `AuthShell`
1001
+ component and the shared `_authShell.scss` mixin.
1002
+ - **Barlow is actually loaded** — `Login.module.scss` had asked for it since the
1003
+ redesign, but nothing in the library imported `@fontsource/barlow`, so every
1004
+ consumer silently fell through to `system-ui`. The family is also read from
1005
+ `--auth-font-family` first, so an app can put its own face on the journey.
1006
+ - **SMS-code 2FA** — `TwoFactorAuth` gains `mode="code"`: the masked
1007
+ destination, expiry messaging, and a working Resend button.
1008
+ - **`mainComponent` on `GenericAuth`** — keep your own app shell while still
1009
+ getting this file's route table, `ProtectedRoute` and please-wait curtain.
1010
+ - **Client portal parity** — `ClientLogin` and `ClientOTPVerify` gain the
1011
+ behaviour of the Next.js portal's own login page: the live contact-type hint,
1012
+ masked contacts, the dev-OTP panel, a back link that keeps what you typed, a
1013
+ configurable resend cooldown, and configurable labels throughout. Route paths
1014
+ are props, so the same screens serve `/client/*` and `/portal/*`.
1015
+ - **`ImpersonateGate` and `LogoutScreen`** — the portal's impersonation
1016
+ hand-off and its logout confirmation, both framework-agnostic and
1017
+ callback-driven (no `next/router`, no cookie writing, no URL reading).
1018
+ - **`CallQueuePop` and `CallQueueSettings`** — the ThroughLife CRM's Zoom call
1019
+ queue pop and its admin page, ported with every host-specific decision turned
1020
+ into a prop. See [Call Queue Components](#call-queue-components).
1021
+ - **Bundler-agnostic env reads** — `Fetch.jsx` and `DataGrid.jsx` read
1022
+ `import.meta.env.VITE_*` directly, which is Vite's interface. Under webpack
1023
+ `import.meta` compiles to a bare `{}`, so the property read threw a
1024
+ TypeError on the Next.js portal's first request and it needed a DefinePlugin
1025
+ shim. Both now go through `readBuildEnv`, which falls back to `process.env`
1026
+ (including the `NEXT_PUBLIC_` prefix Next.js requires). Vite behaviour is
1027
+ unchanged — its value is still found first and still wins.
1028
+ - **`{error}` bodies surface again** — `CustomFetch`'s error path looked at
1029
+ `errors` and `message` and never at `error`, so on a non-200 an
1030
+ `{"error": "…"}` body was dropped entirely and the caller got an empty toast.
1031
+ A last-resort fallback was added *after* the existing checks, so nothing that
1032
+ already surfaced changes.
1033
+ - **Documentation corrected** — the 2FA API section documented `requires_2fa`
1034
+ and a `{user_id, verification_code}` challenge body. Neither was ever what the
1035
+ code sent; the real contract (`requires_two_factor`, `{code, previous_url,
1036
+ remember}`) is now what is written down.
1037
+ - **Debug logging removed** from `TwoFactorAuth`, which shipped a dozen
1038
+ `console.log` calls including the full challenge response.
1039
+
12
1040
  ## Recent Updates (v5.15.10)
13
1041
 
14
1042
  ### Latest Enhancements
@@ -431,6 +1459,31 @@ All existing DropZone implementations continue to work without modification. The
431
1459
 
432
1460
  ### Authentication Components
433
1461
 
1462
+ > **6.7.0 — everything below is configurable, and every default is what 6.6.3
1463
+ > already did.** The auth screens used to hardcode their endpoints, their route
1464
+ > paths and their copy. All three are now props. Passing none of them leaves
1465
+ > the rendering and the network calls exactly as they were, so upgrading is a
1466
+ > no-op until you opt in.
1467
+
1468
+ ##### The `endpoints` object
1469
+
1470
+ One object, threaded from `GenericAuth` into every screen. Partial overrides
1471
+ are merged over the defaults, and nullish/empty entries are ignored (so a key
1472
+ missing from your config cannot blank a URL and leave a screen posting to `''`).
1473
+
1474
+ | Key | Default | Used by |
1475
+ | --- | --- | --- |
1476
+ | `authenticate` | `/login/authenticate` | `Login` |
1477
+ | `twoFactorChallenge` | `/login/two-factor-challenge` | `TwoFactorAuth` |
1478
+ | `twoFactorResend` | `/login/two-factor-resend` | `TwoFactorAuth` (`mode="code"`) |
1479
+ | `azureSso` | `/auth/azure` | `Login` (the Microsoft button) |
1480
+ | `profile` | `/ajax/user/profile` | `GenericAuth` (the session check) |
1481
+ | `passwordForgot` | `/password/forgot` | `Reset` |
1482
+ | `passwordReset` | `/password/reset` | `Verify` |
1483
+
1484
+ `resolveAuthEndpoints`, `DEFAULT_AUTH_ENDPOINTS`, `resolveClientPaths` and
1485
+ `DEFAULT_CLIENT_PATHS` are exported if you want to build on them.
1486
+
434
1487
  #### GenericAuth
435
1488
 
436
1489
  The main component that handles authentication and routing.
@@ -446,6 +1499,8 @@ The main component that handles authentication and routing.
446
1499
  routeConfig={routeConfig} // Route configuration
447
1500
  themeType="primary" // Theme type: 'primary', 'secondary', etc.
448
1501
  enforce2FA={false} // Whether to enforce 2FA (default: false)
1502
+ endpoints={{ profile: '/api/me' }} // 6.7.0 — merged over the defaults
1503
+ mainComponent={MyAppShell} // 6.7.0 — defaults to GenericMain
449
1504
  clientPortalConfig={{
450
1505
  component: <ClientPortalComponent />, // Main component for client portal
451
1506
  urls: {
@@ -454,6 +1509,9 @@ The main component that handles authentication and routing.
454
1509
  logout: '/clientPortal/logout',
455
1510
  profile: '/clientPortal/profile',
456
1511
  },
1512
+ paths: { base: 'client' }, // 6.7.0 — route paths, see below
1513
+ login: { showContactHint: true }, // 6.7.0 — props for ClientLogin
1514
+ verify: { showDevOtp: true }, // 6.7.0 — props for ClientOTPVerify
457
1515
  keys: {
458
1516
  name: ['firstname', 'surname'], // Fields to use for client name display
459
1517
  },
@@ -461,6 +1519,16 @@ The main component that handles authentication and routing.
461
1519
  />
462
1520
  ```
463
1521
 
1522
+ | Prop | Default | Notes |
1523
+ | --- | --- | --- |
1524
+ | `endpoints` | `{}` → the table above | Threaded into `Login`, `TwoFactorAuth`, `Reset`, `Verify` and the session check. |
1525
+ | `mainComponent` | `GenericMain` | The authenticated shell. Pass your own component (or an already-created element, which is cloned) to keep your chrome while still getting this file's route table, `ProtectedRoute` and please-wait curtain. It receives exactly the props `GenericMain` does. |
1526
+ | `mainComponentProps` | `{}` | Extra props for a custom shell that needs something `GenericMain` does not take. |
1527
+ | `config.twoFactorMode` | *(unset)* | `'code'` puts `TwoFactorAuth` into SMS-code mode. Unset leaves it on `'totp'`. |
1528
+ | `clientPortalConfig.paths` | `{base: 'client'}` | Client route paths. `{base: 'portal'}` derives the whole `/portal/*` family; individual keys (`login`, `verify`, `portal`) can be set explicitly and win over the derived ones. |
1529
+ | `clientPortalConfig.login` | `{}` | Spread onto `ClientLogin` — see its prop table. |
1530
+ | `clientPortalConfig.verify` | `{}` | Spread onto `ClientOTPVerify` — see its prop table. |
1531
+
464
1532
  #### Login
465
1533
 
466
1534
  Handles user login with email and password.
@@ -468,40 +1536,438 @@ Handles user login with email and password.
468
1536
  ```jsx
469
1537
  <Login
470
1538
  logo="/path/to/logo.png"
1539
+ loginBg="/path/to/background.jpg"
471
1540
  providers={['azure']} // Optional SSO providers
472
1541
  setSystemAuth={setAuthFunction}
473
1542
  setUserProfile={setUserProfileFunction}
1543
+ config={{ branding: { name: 'Acme', tagline: '…' }, passwordReset: true }}
474
1544
  />
475
1545
  ```
476
1546
 
1547
+ | Prop | Default | Notes |
1548
+ | --- | --- | --- |
1549
+ | `logo`, `loginBg` | — | Panel logo and the brand-side photograph. |
1550
+ | `providers` | `undefined` | `['azure']` renders the Microsoft button and collapses the email form behind a disclosure. |
1551
+ | `setSystemAuth`, `setUserProfile` | — | Required callbacks. |
1552
+ | `config.branding` | `{}` | `{eyebrow, name, tagline, signInHint, footer}`. Absent entries simply do not render. |
1553
+ | `config.passwordReset` | `true` | `false` hides the "Forgot password?" link. |
1554
+ | `endpoints` | *(defaults)* | Uses `authenticate` and `azureSso`. |
1555
+ | `twoFactorPath` | `'/2fa'` | Where a `requires_two_factor` response navigates. |
1556
+ | `resetPath` | `'/reset'` | Target of the "Forgot password?" link. |
1557
+
477
1558
  #### TwoFactorAuth
478
1559
 
479
- Handles 2FA verification.
1560
+ Handles 2FA verification. **Redesigned in 6.7.0** onto the same two-pane frame
1561
+ as `Login` — the pre-6.1 centred card it used to render is gone, so the whole
1562
+ sign-in journey now looks like one journey.
480
1563
 
481
1564
  ```jsx
1565
+ {/* Authenticator-app code — the default, unchanged from 6.6.3 */}
482
1566
  <TwoFactorAuth
483
1567
  logo="/path/to/logo.png"
484
1568
  setSystemAuth={setAuthFunction}
485
1569
  setUserProfile={setUserProfileFunction}
486
1570
  />
1571
+
1572
+ {/* Texted code, with a working Resend and expiry messaging */}
1573
+ <TwoFactorAuth
1574
+ logo="/path/to/logo.png"
1575
+ mode="code"
1576
+ expiryMinutes={10}
1577
+ resendCooldown={30}
1578
+ setSystemAuth={setAuthFunction}
1579
+ setUserProfile={setUserProfileFunction}
1580
+ />
487
1581
  ```
488
1582
 
1583
+ | Prop | Default | Notes |
1584
+ | --- | --- | --- |
1585
+ | `mode` | `'totp'` | `'totp'` reads the code from an authenticator app: no expiry line, no Resend. `'code'` is for a texted code: it shows the masked destination, the expiry, and a Resend button. |
1586
+ | `endpoints` | *(defaults)* | Uses `twoFactorChallenge` and, in `code` mode, `twoFactorResend`. |
1587
+ | `expiryMinutes` | `10` | Substituted into `copy.expiry`. `code` mode only. |
1588
+ | *(router state)* | — | `Login` hands over `{challenge, userData, maskedContact, previousUrl, remember}`. `userData` is **null on a code-driver challenge**; nothing on this screen may assume otherwise. |
1589
+ | `resendCooldown` | `0` | Seconds to keep Resend disabled *after* a successful send. `0` disables it only while the request is in flight. `code` mode only. |
1590
+ | `loginPath` | `'/login'` | "Back to Login", and the bounce when a challenge session has expired. |
1591
+ | `successPath` | `'/'` | `'/'` means **hand off to `GenericAuth`**: the screen sets the auth state, raises no navigation of its own, and the curtain plus profile check carry the user in. Any other value is a consumer asking to land somewhere specific, which `GenericAuth` cannot do, so it gets a real navigation under a full-screen curtain. A `previousUrl` from before sign-in always outranks this and always gets a real navigation. |
1592
+ | `copy` | *(see below)* | Merged over the defaults; override only the lines you want to change. |
1593
+ | `config.branding` | `{}` | Same shape as `Login`'s. |
1594
+
1595
+ `copy` keys: `title`, `totpSubtitle`, `codeSubtitle`, `sentTo`, `fieldLabel`,
1596
+ `submit`, `submitting`, `expiry` (`{minutes}`), `resend`, `resending`,
1597
+ `resendIn` (`{seconds}`), `resent`, `help`, `back`, `remembered`, `leaving`,
1598
+ `invalidLength`, `failed`, `expired`, `resendFailed`.
1599
+
1600
+ `submitting` is shown for the whole request **and** the transition that follows
1601
+ it; `resending` is the resend link's label while its own request is in flight;
1602
+ `leaving` captions the full-screen curtain during the hand-off.
1603
+
1604
+ The masked destination shown in `code` mode comes from the router state the
1605
+ login step hands over (`maskedContact`), falling back to
1606
+ `userData?.masked_contact`. The packages' `AuthController` sends neither today,
1607
+ so the chip is normally not rendered — see the API section below.
1608
+
489
1609
  #### Reset
490
1610
 
491
- Password reset request form.
1611
+ Password reset request form. **Redesigned in 6.7.0** onto the two-pane frame.
492
1612
 
493
1613
  ```jsx
494
- <Reset logo="/path/to/logo.png" setSystemAuth={setAuthFunction} />
1614
+ <Reset logo="/path/to/logo.png" loginPath="/login" />
495
1615
  ```
496
1616
 
1617
+ | Prop | Default | Notes |
1618
+ | --- | --- | --- |
1619
+ | `endpoints` | *(defaults)* | Uses `passwordForgot`. |
1620
+ | `loginPath` | `'/login'` | The "Back to Login" link. |
1621
+ | `copy` | *(see below)* | Merged over the defaults. |
1622
+ | `config.branding` | `{}` | Same shape as `Login`'s. |
1623
+
1624
+ `copy` keys: `title`, `subtitle`, `fieldLabel`, `submit`, `submitting`, `sent`,
1625
+ `back`, `required`, `failed`.
1626
+
1627
+ Once the request is in, the form is replaced by a confirmation rather than
1628
+ re-offered — submitting twice only invalidates the first link.
1629
+
497
1630
  #### Verify
498
1631
 
499
- Password reset verification form.
1632
+ Password reset verification form (`/verify/:code`). **Redesigned in 6.7.0**
1633
+ onto the two-pane frame.
1634
+
1635
+ ```jsx
1636
+ <Verify logo="/path/to/logo.png" showRules />
1637
+ ```
1638
+
1639
+ | Prop | Default | Notes |
1640
+ | --- | --- | --- |
1641
+ | `endpoints` | *(defaults)* | Uses `passwordReset`. |
1642
+ | `showRules` | `true` | The live password-rules checklist, which ticks itself off as the user types. The pre-6.7.0 behaviour was a five-line `<br />`-separated toast *after* a rejected submit. |
1643
+ | `rules` | `PASSWORD_RULES` | `[{label, test}]`. Display only — the submit is still gated by `validator.isStrongPassword`, so the two cannot disagree. |
1644
+ | `loginPath` | `'/login'` | Where a successful reset lands, and the "Back to Login" link. |
1645
+ | `copy` | *(see below)* | Merged over the defaults. |
1646
+ | `config.branding` | `{}` | Same shape as `Login`'s. |
1647
+
1648
+ `copy` keys: `title`, `subtitle`, `passwordLabel`, `repeatLabel`, `submit`,
1649
+ `submitting`, `back`, `success`, `required`, `mismatch`, `weak`, `failed`.
1650
+
1651
+ #### AuthShell
1652
+
1653
+ The two-pane frame `TwoFactorAuth`, `Reset` and `Verify` render inside. Exported
1654
+ so an app can build a fourth screen that matches the other three.
1655
+
1656
+ ```jsx
1657
+ import styles from './MyScreen.module.scss'; // must @include auth-shell
1658
+
1659
+ <AuthShell
1660
+ styles={styles}
1661
+ logo={logo}
1662
+ loginBg={loginBg}
1663
+ branding={{ eyebrow, name, tagline }}
1664
+ title="…"
1665
+ subtitle="…"
1666
+ footer="…"
1667
+ >
1668
+ {/* your form */}
1669
+ </AuthShell>;
1670
+ ```
1671
+
1672
+ `AuthBrandPanel` is the brand side on its own, exported for the same reason:
1673
+ the staff `Login`, `AuthShell` and the client split layout all render exactly
1674
+ it, against the shared `auth-brand-panel` mixin in `_authBrandPanel.scss`.
1675
+
1676
+ The `styles` module is supplied by the caller — each screen owns its own CSS
1677
+ module so module scoping stays intact. Share the look by putting
1678
+ `@use './authShell' as *; @include auth-shell;` at the top of it, then add only
1679
+ what is particular to your screen. Set `--auth-font-family` to use a face other
1680
+ than Barlow across the whole journey.
1681
+
1682
+ ### Client Portal Sign-In
1683
+
1684
+ Two steps — identify yourself, then enter the code — available three ways:
1685
+ `ClientAuth` (both on one page, no router needed), or `ClientLogin` and
1686
+ `ClientOTPVerify` mounted on separate routes.
1687
+
1688
+ #### The `protocol` prop
1689
+
1690
+ There are two OTP wire protocols in the wild and they disagree about nearly
1691
+ everything. Before 6.8.0 only one of them was expressible, which is what
1692
+ blocked the Next.js portal from adopting these screens at all.
1693
+
1694
+ | | `uuid` *(default)* | `contact` |
1695
+ | --- | --- | --- |
1696
+ | Request body | `{email, location}` | `{contact}` |
1697
+ | Verify body | `{email, uuid, otp, previous_url}` | `{contact, otp_code, minimal_response}` |
1698
+ | Challenge id | required — step two redirects without one | **none in the contract** |
1699
+ | Success | `body.success === true` | `body.error === ''` |
1700
+ | Failure | `{message}` / `{errors}` | non-200 `{error: '…'}` |
1701
+ | Session | cookie, set server-side | `access_token` in the body |
1702
+ | Resend | `{email, resend: true}` | just another request |
1703
+
1704
+ `protocol="contact"` is the configuration for ThroughLife's `/api/auth/*`
1705
+ routes (visns-packages' `OtpController`). `protocol` also accepts an object of
1706
+ overrides, optionally naming what it starts from:
1707
+ `{extends: 'contact', contactField: 'identifier'}`. An unknown name falls back
1708
+ to the default rather than throwing — a typo in a prop should not white-screen
1709
+ a login page.
1710
+
1711
+ `CLIENT_AUTH_PROTOCOLS`, `resolveClientProtocol` and `verifyContactFor` are
1712
+ exported for anything more exotic.
1713
+
1714
+ #### ClientAuth
1715
+
1716
+ Both steps on one page, with the challenge held in state instead of in router
1717
+ state. It needs no router, so it mounts cleanly in a Next.js App Router page,
1718
+ and it is the whole of the "no adapter code" story — supply endpoints, a
1719
+ protocol name and an `onAuthenticated`, and write nothing that knows a request
1720
+ body from a response field.
1721
+
1722
+ ```jsx
1723
+ 'use client';
1724
+ import { useRouter } from 'next/navigation';
1725
+ import { ClientAuth } from '@visns-studio/visns-components';
1726
+
1727
+ export default function PortalLoginPage() {
1728
+ const router = useRouter();
1729
+
1730
+ return (
1731
+ <ClientAuth
1732
+ protocol="contact"
1733
+ urls={{
1734
+ login: '/api/auth/request-otp',
1735
+ verify: '/api/auth/login-otp',
1736
+ }}
1737
+ usernameDomain="virtualportal.com.au"
1738
+ showContactHint
1739
+ showMaskedContact
1740
+ showContactField
1741
+ showDevOtp
1742
+ resendCooldown={0}
1743
+ loginLabels={{ submit: 'Send Code', submitting: 'Please wait...' }}
1744
+ verifyLabels={{ submit: 'Login', submitting: 'Please wait...' }}
1745
+ onAuthenticated={(user, token) => {
1746
+ setCookie('portal_token', token, { maxAge: 28800 });
1747
+ setUserData(user);
1748
+ router.push('/portal/dashboard');
1749
+ }}
1750
+ />
1751
+ );
1752
+ }
1753
+ ```
1754
+
1755
+ | Prop | Default | Notes |
1756
+ | --- | --- | --- |
1757
+ | `protocol`, `urls`, `paths`, `contactField`, `logo`, `showDevOtp`, `onError` | — | Passed to both steps. |
1758
+ | `showContactHint`, `contactHints`, `usernameDomain`, `loginCopy`, `loginLabels` | — | Step one. |
1759
+ | `resendCooldown`, `showMaskedContact`, `showContactField`, `minimalResponse`, `verifyCopy`, `verifyLabels` | — | Step two. |
1760
+ | `onAuthenticated` | — | `(user, token, rawResponse)`. |
1761
+ | `onCodeSent` | `undefined` | Notified with the challenge each time a code is sent. |
1762
+ | `loginProps`, `verifyProps` | `{}` | Merged last onto either step, for anything that has to differ. |
1763
+ | `layout` | `'card'` | `'card'` is the original box centred in the host page's space — byte-identical to pre-6.8.3. `'split'` is the two-pane brand layout described above. Anything unrecognised falls back to `'card'`. |
1764
+ | `loginBg` | `undefined` | Split only: the brand panel's photograph. Ignored by `'card'`. |
1765
+ | `branding` | `undefined` | Split only: `{eyebrow, name, tagline, footer}`, rendered in the same type hierarchy as the staff `Login`'s brand panel — eyebrow with its accent rule, name at display size, tagline beneath, footer at the foot of the form column. Ignored by `'card'`. |
1766
+
1767
+ `layout`, `loginBg` and `branding` are also accepted directly by `ClientLogin`
1768
+ and `ClientOTPVerify`, for a consumer mounting the two steps on separate
1769
+ routes.
1770
+
1771
+ ##### Sizing the split
1772
+
1773
+ The split fills its parent, so give it somewhere to fill. In a page that keeps
1774
+ its own header and footer:
1775
+
1776
+ ```jsx
1777
+ <div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh' }}>
1778
+ <Header />
1779
+ <ClientAuth layout="split" /* … */ />
1780
+ <Footer />
1781
+ </div>
1782
+ ```
1783
+
1784
+ The section carries `flex: 1 1 auto` and `align-self: stretch`, so a flex
1785
+ column like this needs nothing further. A parent with an explicit height works
1786
+ too (`height: 100%`); a parent with no height at all falls back to the
1787
+ section's `min-height: 24rem`.
1788
+
1789
+ ##### Throughlife portal configuration
1790
+
1791
+ The whole integration, for the record — the split layout, the contact protocol,
1792
+ and the consumer owning its own session:
1793
+
1794
+ ```jsx
1795
+ 'use client';
1796
+ import { useRouter } from 'next/navigation';
1797
+ import { ClientAuth } from '@visns-studio/visns-components';
1798
+
1799
+ export default function PortalLoginPage() {
1800
+ const router = useRouter();
1801
+
1802
+ return (
1803
+ <ClientAuth
1804
+ layout="split"
1805
+ loginBg="/images/portal-hero.jpg"
1806
+ branding={{
1807
+ eyebrow: 'Client portal',
1808
+ name: 'Throughlife',
1809
+ tagline: 'Your plan, your documents, in one place.',
1810
+ footer: '© Throughlife Financial Planning',
1811
+ }}
1812
+ protocol="contact"
1813
+ urls={{
1814
+ login: '/api/auth/request-otp',
1815
+ verify: '/api/auth/login-otp',
1816
+ }}
1817
+ usernameDomain="virtualportal.com.au"
1818
+ showContactHint
1819
+ showMaskedContact
1820
+ showContactField
1821
+ showDevOtp
1822
+ resendCooldown={0}
1823
+ loginLabels={{ submit: 'Send Code', submitting: 'Please wait...' }}
1824
+ verifyLabels={{ submit: 'Login', submitting: 'Please wait...' }}
1825
+ onAuthenticated={(user, token) => {
1826
+ setCookie('portal_token', token, { maxAge: 28800 });
1827
+ setUserData(user);
1828
+ router.push('/portal/dashboard');
1829
+ }}
1830
+ />
1831
+ );
1832
+ }
1833
+ ```
1834
+
1835
+ The back link clears the challenge and returns to step one with what was typed
1836
+ still in the field.
1837
+
1838
+ #### ClientLogin
1839
+
1840
+ Step one on its own. Every 6.7.0/6.8.0 addition is opt-in — with no new props
1841
+ this renders exactly what 6.6.3 rendered.
1842
+
1843
+ | Prop | Default | Notes |
1844
+ | --- | --- | --- |
1845
+ | `urls.login` | — | The POST this screen makes. |
1846
+ | `protocol` | `'uuid'` | See the table above. |
1847
+ | `paths` | `{base: 'client'}` | Route paths — `resolveClientPaths` shape. |
1848
+ | `onChallengeIssued` | `undefined` | `(challenge) => void`, called once a code is sent. **When present the screen does not navigate** — the consumer owns what happens next. The challenge is `{contact, rawContact, challengeId, maskedContact, contactMethod, devOtp, message, previousUrl}`, which is exactly `ClientOTPVerify`'s `challenge` prop. |
1849
+ | `onNavigate` | `undefined` | `(path, options) => void`. Used before react-router's `navigate`, which is used before a plain page load. |
1850
+ | `initialContact` | `undefined` | Prefills the field. |
1851
+ | `layout`, `loginBg`, `branding` | `'card'`, — , — | As on `ClientAuth`; pass them here when mounting the steps on separate routes. |
1852
+ | `showContactHint` | `false` | The live "Email address detected" / "Mobile number detected" / "Username detected" line under the field. |
1853
+ | `contactHints` | *(defaults)* | Merged over `DEFAULT_CONTACT_HINTS`. |
1854
+ | `usernameDomain` | `null` | Completes a bare username to `name@domain`. Emails and phone numbers are untouched. |
1855
+ | `contactField` | *(the protocol's)* | Overrides the request-body key: `email` under `uuid`, `contact` under `contact`. |
1856
+ | `showDevOtp` | `false` | Carry the response's dev OTP through to step two. Never honoured in production. |
1857
+ | `onError` | `undefined` | Notified with the normalised message. |
1858
+ | `labels` | `{submit: 'Continue', submitting: 'Sending...'}` | **The 6.6.3 strings, not the portal's.** For portal parity pass `{submit: 'Send Code', submitting: 'Please wait...'}`. |
1859
+ | `copy` | *(see below)* | Merged over the defaults. |
1860
+
1861
+ `copy` keys: `title`, `subtitle`, `fieldLabel`, `placeholder`, `required`,
1862
+ `notFound`, `sending`, `sent`.
1863
+
1864
+ #### ClientOTPVerify
1865
+
1866
+ Step two on its own.
1867
+
1868
+ | Prop | Default | Notes |
1869
+ | --- | --- | --- |
1870
+ | `urls.verify`, `urls.login` | — | The verify POST, and the resend. |
1871
+ | `protocol` | `'uuid'` | See the table above. **This is what decides whether a challenge id is required** — under `contact` there is none, and demanding one bounced every user straight back to step one. |
1872
+ | `challenge` | `undefined` | The object `ClientLogin`'s `onChallengeIssued` hands over. Supplying it is what lets this screen render outside a router; without it, router state is read (the 6.6.3 hand-off, both old and new key spellings). |
1873
+ | `onAuthenticated` | `undefined` | `(user, token, rawResponse)` on a successful verification. **When present the component does nothing else** — no `setUserProfile`, no `setSystemAuth`, no navigation. That is the only way an app can write its own cookie from the bearer token. `token` is `null` under `uuid`, whose backend sets a cookie server-side. |
1874
+ | `onBack` | `undefined` | Replaces the back navigation; receives the challenge. A single-page consumer clears its challenge state here. Passing it also stops this screen redirecting when it decides it has nothing to answer. |
1875
+ | `onNavigate` | `undefined` | As on `ClientLogin`. |
1876
+ | `minimalResponse` | `true` | `contact` protocol: ask for the cookie-sized user payload. `false` returns the full model. |
1877
+ | `resendCooldown` | `60` | Seconds Resend stays disabled after a send. **60 is what 6.6.3 hardcoded**; the portal has none, so pass `0` for parity. |
1878
+ | `showMaskedContact` | `false` | Show the server's `masked_contact` in the sub-line (falling back to a locally-computed mask). |
1879
+ | `showContactField` | `false` | Render a disabled contact field above the code field, echoing the raw contact, with `Code sent to: {masked}` captioned beneath it — the layout the portal's OTP step used before it adopted this component. The destination moves out of the sub-line when this is on, so it is never said twice. The field is `readOnly`, `tabIndex={-1}` and never submitted: the verify body is built by the protocol from the challenge, not read off the form. |
1880
+ | `showDevOtp` | `false` | The development-only OTP panel with an "Auto-fill OTP" button. Gated on `NODE_ENV !== 'production'` as well as this prop, so a build that leaves it on cannot print a live code. |
1881
+ | `backKeepsContact` | `false` | The back navigation carries the identifier so step one is not blank. |
1882
+ | `contactField` | *(the protocol's)* | |
1883
+ | `layout`, `loginBg`, `branding` | `'card'`, — , — | As on `ClientAuth`. Every field, hint and panel — including `showContactField` and the dev-OTP panel — renders identically in both layouts. |
1884
+ | `onError` | `undefined` | Notified with the normalised message. |
1885
+ | `labels` | `{submit: 'Verify', submitting: 'Verifying...'}` | **The 6.6.3 strings.** For portal parity pass `{submit: 'Login', submitting: 'Please wait...'}`. |
1886
+ | `copy` | *(see below)* | Merged over the defaults. |
1887
+
1888
+ `copy` keys: `title`, `subtitle`, `fieldLabel`, `placeholder`,
1889
+ `contactFieldLabel`, `sentTo` (`{contact}`), `resendPrompt`, `resend`,
1890
+ `resendIn` (`{seconds}`), `back`, `devOtpLabel`, `devOtpAction`,
1891
+ `invalidLength`, `invalidCode`, `resendFailed`, `verifying`, `resending`,
1892
+ `resent`, `success`.
1893
+
1894
+ `contactFieldLabel` and `sentTo` are only rendered under `showContactField`.
1895
+
1896
+ Not opt-in, because it cannot change the outcome for input that already worked:
1897
+ the code field **strips** non-digits and clamps to 6 instead of ignoring any
1898
+ value containing one, so a code pasted out of a text message as `123 456` lands
1899
+ rather than being dropped.
1900
+
1901
+ #### ImpersonateGate
1902
+
1903
+ "Open the client's portal as them" — a staff member follows a one-use link, this
1904
+ exchanges its token for a session and the browser lands in the portal. A
1905
+ curtain, not a screen: a spinner, or one error card.
1906
+
1907
+ Framework-agnostic on purpose. It does not read the URL, write cookies, or know
1908
+ what a router is, so the same component serves a Next.js app and a React Router
1909
+ SPA.
500
1910
 
501
1911
  ```jsx
502
- <Verify logo="/path/to/logo.png" setSystemAuth={setAuthFunction} />
1912
+ <ImpersonateGate
1913
+ token={searchParams.get('token')}
1914
+ validateUrl="/api/validateImpersonationToken"
1915
+ onAuthenticated={(user, token) => {
1916
+ setCookie('portal_token', token, { maxAge: 3600 });
1917
+ setUserData(user);
1918
+ }}
1919
+ redirectPath="/portal/dashboard"
1920
+ loginPath="/portal"
1921
+ onNavigate={(path) => router.push(path)}
1922
+ />
503
1923
  ```
504
1924
 
1925
+ | Prop | Default | Notes |
1926
+ | --- | --- | --- |
1927
+ | `token` | — | Extracted from the URL **by the consumer**. |
1928
+ | `validateUrl` | `/api/validateImpersonationToken` | POST target. |
1929
+ | `buildBody` | `(t) => ({token: t, minimal_response: true})` | Request body builder. |
1930
+ | `onAuthenticated` | — | `(user, token)`. Where you persist your own session. May return a promise; it is awaited before the settle delay starts. |
1931
+ | `settleDelay` | `500` | Milliseconds between `onAuthenticated` and the redirect, so a `Set-Cookie` written in the callback is visible to the next request. |
1932
+ | `redirectPath` | `/portal/dashboard` | Where a successful hand-off lands. |
1933
+ | `loginPath` | `/portal` | The error card's "Go to Login" button. |
1934
+ | `onNavigate` | `undefined` | `(path) => void`. Absent, a full page load is used — usually what a session hand-off wants. |
1935
+ | `onError` | `undefined` | Notified with the normalised message when validation fails. |
1936
+ | `spinner` | *(built-in)* | Any node. |
1937
+ | `copy` | `{loading, errorTitle, loginAction, missingToken, failed}` | Merged over the defaults. |
1938
+
1939
+ **Security:** neither the token nor the URL is logged, at any level, in any
1940
+ branch. The token is a bearer credential for someone else's account.
1941
+
1942
+ #### LogoutScreen
1943
+
1944
+ The 1.5s animated confirmation shown while a session is torn down. Signing out
1945
+ is the one action where "did that work?" matters and where an instant redirect
1946
+ leaves no evidence that it did.
1947
+
1948
+ Callback-driven: this component clears nothing itself, because a library has no
1949
+ business deciding which of your cookies and storage keys constitute a session.
1950
+
1951
+ ```jsx
1952
+ <LogoutScreen
1953
+ onLogout={() => {
1954
+ removeCookie('portal_token');
1955
+ clearUserData();
1956
+ }}
1957
+ redirectPath="/portal"
1958
+ />
1959
+ ```
1960
+
1961
+ | Prop | Default | Notes |
1962
+ | --- | --- | --- |
1963
+ | `onLogout` | `undefined` | Runs once on mount (guarded against StrictMode's double invocation). |
1964
+ | `onComplete` | `undefined` | Runs after `delay`. Takes precedence over `redirectPath`. |
1965
+ | `redirectPath` | `undefined` | Hard navigation performed after `delay` when `onComplete` is absent. |
1966
+ | `delay` | `1500` | How long the confirmation is held. |
1967
+ | `icon` | Lucide `LogOut` | Any node. |
1968
+ | `showSpinner` | `true` | |
1969
+ | `copy` | `{title: 'Logging out...', message: 'Please wait while we log you out.'}` | Merged over the defaults. |
1970
+
505
1971
  #### Profile
506
1972
 
507
1973
  User profile management.
@@ -513,6 +1979,87 @@ User profile management.
513
1979
  />
514
1980
  ```
515
1981
 
1982
+ ### Call Queue Components
1983
+
1984
+ Ported from the ThroughLife CRM in 6.7.0. Two ends of one feature: the pop that
1985
+ tells staff a call is ringing, and the settings page where the codes it dials
1986
+ are configured.
1987
+
1988
+ #### CallQueuePop
1989
+
1990
+ A permission-gated, root-level stack of "call is ringing" cards for every Zoom
1991
+ Phone call queue, so monitoring staff can see an incoming call without watching
1992
+ the Zoom client. Render it once, inside the authenticated shell.
1993
+
1994
+ Everything that talks to the server is optional and fails silently, so the
1995
+ component is safe to ship ahead of its backend: with neither the snapshot
1996
+ endpoint nor an Echo instance present it renders nothing and logs nothing.
1997
+
1998
+ ```jsx
1999
+ <CallQueuePop
2000
+ userProfile={userProfile}
2001
+ echo={() => getEcho()} // lazily created — only a gated-in user connects
2002
+ pickupEnabled={false}
2003
+ trialBadge={false}
2004
+ />
2005
+ ```
2006
+
2007
+ | Prop | Default | Notes |
2008
+ | --- | --- | --- |
2009
+ | `userProfile` | `undefined` | Gates the pop. Read for the Spatie shape `roles[].permissions[].name`, and for direct permissions. |
2010
+ | `echo` | `null` | The Laravel Echo instance, **or a factory function** returning one. A function is called lazily inside the subscription effect, so only a permission-gated user ever opens a connection. |
2011
+ | `channel` | `'call-queue-monitor'` | Fallback Echo channel. The live snapshot's own `channel` field wins once it lands — which is how the CRM scopes the channel per environment. Pass `null` to subscribe only once the server has named a channel. |
2012
+ | `pickupEnabled` | `false` | Replaces the CRM's hardcoded `PICKUP_ENABLED`. `false` renders Pick up visibly inert with a "coming soon" title; `true` makes it a live `zoomphonecall://` dial. |
2013
+ | `trialBadge` | `true` | `true` renders "Trial run — the call pop is being tested". A string replaces the copy; `false` hides the banner. |
2014
+ | `monitorPermission` | `'Call Queue Monitor'` | The Spatie permission name. |
2015
+ | `endpoints` | `{live: '/ajax/call-queue/live', clientTasks: (id) => '/ajax/call-queue/client/{id}/tasks'}` | Merged over the defaults. |
2016
+ | `onOpenTask` | `null` | `(task) => void`. Absent, a task row opens `taskUrl(task)` in a 1440×1024 popup window — the CRM's current behaviour. |
2017
+ | `taskUrl` | `(task) => '/tasks/detail/{id}'` | Builder, or a string template containing `{id}`. |
2018
+ | `clientUrl` | `(client) => '/clients/{id}'` | Ditto. |
2019
+ | `clientTasksUrl` | `(clientId) => '/tasks/0/{id}'` | The "View all tasks" link. |
2020
+ | `onOpenCalendar` | `null` | `(event) => void`. Absent, the "Next: …" line is a `<Link>` to `calendarPath`. |
2021
+ | `calendarPath` | `'/calendar'` | |
2022
+ | `callWorkspacePath` | `'/call/{number}'` | Template, or `(workspaceId, call) => path`. `{number}` is the `61…` form the workspace route expects. |
2023
+ | `syncChannelName` | `'throughlife-call-queue-pop'` | The `BroadcastChannel` that keeps every open tab's stack in step. Falsy switches cross-tab sync off. |
2024
+ | `demoEnabled` | `true` | Registers `window.callPopDemo()` / `window.callPopClear()` for reviewing the UI without a backend. |
2025
+
2026
+ **Payload contract.** Snake_case and camelCase are both accepted, so a Laravel
2027
+ resource passes through untouched: `call_id`/`callId`, `queue_id`/`queueId`,
2028
+ `queue_name`/`queueName`, `caller_number`/`callerNumber`, `caller_name`,
2029
+ `started_at`, plus `client` (already resolved server-side) and, for demo cards,
2030
+ `tasks`. A call with no id is dropped; a queue with no name gets `Call Queue`.
2031
+ The snapshot's `pickup_codes` map is keyed by Zoom call queue id — a queue
2032
+ absent from it still pops, its card simply has no Pick up button.
2033
+
2034
+ Named exports for testing: `toLocalDigits`, `formatAuPhone`, `formatEventDate`,
2035
+ `formatDueDate`, `toCallWorkspaceId`, `normaliseCall`, `normalisePickupCodes`,
2036
+ `formatElapsed`, `hasMonitorPermission`, `clientDetails`.
2037
+
2038
+ #### CallQueueSettings
2039
+
2040
+ The admin table behind the pop: one row per Zoom call queue, carrying the
2041
+ pickup code the pop tells staff to dial and whether the queue may pop at all.
2042
+
2043
+ ```jsx
2044
+ <CallQueueSettings
2045
+ endpoints={{ list: '/ajax/call-queue/settings' }}
2046
+ onSaved={(queue) => console.log('saved', queue.queue_id)}
2047
+ />
2048
+ ```
2049
+
2050
+ | Prop | Default | Notes |
2051
+ | --- | --- | --- |
2052
+ | `endpoints` | `{list: '/ajax/call-queue/settings', save: (id) => '/ajax/call-queue/settings/{id}'}` | `save` also accepts an `{id}` template. |
2053
+ | `title` | `'Call Queues'` | |
2054
+ | `breadcrumb` | *(unset)* | Unset renders the CRM's `Settings > Call Queues` header. `null` renders no header. Any React node replaces it. |
2055
+ | `intro` | *(the CRM copy)* | The muted line above the table. |
2056
+ | `infoNote` | *(the CRM copy)* | The callout explaining that Zoom will not accept the digits over its API. |
2057
+ | `codePrefix` | `'*99'` | The dial prefix shown as an adornment on the code input. |
2058
+ | `onSaved` | `null` | `(queue) => void`, fired after a successful save. |
2059
+
2060
+ Named exports for testing: `validateCode`, `cleanCode`, `formatAuNumber`,
2061
+ `titleCase`.
2062
+
516
2063
  ### DataGrid Component
517
2064
 
518
2065
  A powerful data table component with sorting, filtering, and pagination. The DataGrid features a modular column renderer architecture with support for 28 different column types.
@@ -3219,11 +4766,16 @@ This grouping is handled internally by the GenericAuth component, so you don't n
3219
4766
  ### How 2FA Works
3220
4767
 
3221
4768
  1. User enters email and password on the login screen
3222
- 2. If credentials are valid and 2FA is required, the backend returns `requires_2fa: true`
3223
- 3. The user is redirected to the 2FA verification screen
4769
+ 2. If credentials are valid and 2FA is required, the backend returns `requires_two_factor: true`
4770
+ 3. The user is redirected to the 2FA verification screen (`/2fa` by default; `twoFactorPath` on `Login` moves it)
3224
4771
  4. User enters the verification code
3225
4772
  5. If the code is valid, the user is authenticated and redirected to the main application
3226
4773
 
4774
+ > **Note (6.7.0):** this section previously documented `requires_2fa` and a
4775
+ > `{user_id, verification_code}` challenge body. Neither was ever what the code
4776
+ > sent — the field names below are the real contract, taken from
4777
+ > `Login.jsx` and `TwoFactorAuth.jsx`.
4778
+
3227
4779
  ### Setting Up 2FA
3228
4780
 
3229
4781
  Users can set up 2FA through their profile page. The setup process includes:
@@ -3268,12 +4820,28 @@ The login form includes a "Remember Me" checkbox that persists through the 2FA v
3268
4820
 
3269
4821
  ### API Integration for 2FA
3270
4822
 
4823
+ `POST /login/authenticate` (configurable — `endpoints.authenticate`)
4824
+
4825
+ Request:
4826
+
4827
+ ```json
4828
+ {
4829
+ "email": "user@example.com",
4830
+ "password": "…",
4831
+ "remember": true,
4832
+ "location": "/optional-previous-url"
4833
+ }
4834
+ ```
4835
+
3271
4836
  #### Login Endpoint Response (when 2FA is required)
3272
4837
 
4838
+ TOTP echoes the user, because its challenge screen renders the account it is
4839
+ challenging:
4840
+
3273
4841
  ```json
3274
4842
  {
3275
4843
  "error": "",
3276
- "requires_2fa": true,
4844
+ "requires_two_factor": true,
3277
4845
  "user": {
3278
4846
  "id": "user_id",
3279
4847
  "email": "user@example.com",
@@ -3282,14 +4850,44 @@ The login form includes a "Remember Me" checkbox that persists through the 2FA v
3282
4850
  }
3283
4851
  ```
3284
4852
 
4853
+ **The code (SMS) driver returns `user: null`, deliberately** — there the account
4854
+ is identified by a code sent out of band, so echoing the record would hand an
4855
+ unauthenticated caller a user lookup:
4856
+
4857
+ ```json
4858
+ {
4859
+ "error": "",
4860
+ "previous": "",
4861
+ "user": null,
4862
+ "requires_two_factor": true
4863
+ }
4864
+ ```
4865
+
4866
+ `Login` therefore marks the hand-over with `challenge: true` rather than
4867
+ relying on the user payload, and `TwoFactorAuth` bounces only when the router
4868
+ state is absent entirely (a direct URL hit or a reload). Anything that keys
4869
+ "is a challenge in progress?" off `userData` breaks every SMS sign-in: the code
4870
+ arrives and the screen to type it into never appears. `hasChallengeState` is
4871
+ exported if you need the same decision elsewhere.
4872
+
4873
+ Neither driver sends a masked contact on the challenge response, and the resend
4874
+ answers a bare `{"error": ""}`, so `TwoFactorAuth`'s destination chip is not
4875
+ rendered against the current backend. `Login` reads `masked_contact` and threads
4876
+ it through anyway, so a backend that grows the field needs no change here.
4877
+
4878
+ An `error` of `""` means success. A non-empty `error` is shown to the user and
4879
+ the credentials fields are marked invalid. `previous` (when present and
4880
+ non-empty) is followed instead of the default landing page.
4881
+
3285
4882
  #### 2FA Verification Endpoint
3286
4883
 
4884
+ `POST /login/two-factor-challenge` (configurable — `endpoints.twoFactorChallenge`)
4885
+
3287
4886
  Request:
3288
4887
 
3289
4888
  ```json
3290
4889
  {
3291
- "user_id": "user_id",
3292
- "verification_code": "123456",
4890
+ "code": "123456",
3293
4891
  "previous_url": "/optional-redirect-url",
3294
4892
  "remember": true
3295
4893
  }
@@ -3297,19 +4895,26 @@ Request:
3297
4895
 
3298
4896
  Response:
3299
4897
 
4898
+ A successful challenge answers with the user object itself (no envelope). A
4899
+ rejected code answers **200** with `{"error": "…"}` — a 401 is reserved for a
4900
+ challenge whose session has expired, which bounces the user back to the login
4901
+ screen:
4902
+
3300
4903
  ```json
3301
4904
  {
3302
- "error": "",
3303
- "user": {
3304
- "id": "user_id",
3305
- "email": "user@example.com",
3306
- "name": "User Name",
3307
- "roles": ["admin"]
3308
- },
3309
- "previous": "/optional-redirect-url"
4905
+ "id": "user_id",
4906
+ "email": "user@example.com",
4907
+ "name": "User Name",
4908
+ "roles": ["admin"]
3310
4909
  }
3311
4910
  ```
3312
4911
 
4912
+ #### 2FA Resend Endpoint (`mode="code"` only)
4913
+
4914
+ `POST /login/two-factor-resend` (configurable — `endpoints.twoFactorResend`),
4915
+ with an empty body. Answers `{"error": ""}` on success, `{"error": "…"}`
4916
+ otherwise. Only ever called when `TwoFactorAuth` is in `mode="code"`.
4917
+
3313
4918
  #### 2FA Setup Endpoint Response
3314
4919
 
3315
4920
  ```json