@visns-studio/visns-components 6.6.3 → 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.
- package/README.md +1658 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +619 -158
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/AutoGrowCell.jsx +141 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/DataGrid.module.scss +10 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/styles/global-datagrid.css +29 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- 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"
|
|
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.
|
|
500
1634
|
|
|
501
1635
|
```jsx
|
|
502
|
-
<Verify logo="/path/to/logo.png"
|
|
1636
|
+
<Verify logo="/path/to/logo.png" showRules />
|
|
503
1637
|
```
|
|
504
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.
|
|
1910
|
+
|
|
1911
|
+
```jsx
|
|
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
|
+
/>
|
|
1923
|
+
```
|
|
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.
|
|
@@ -607,6 +2154,41 @@ The DataGrid component utilizes a modular column renderer system with 28 special
|
|
|
607
2154
|
|
|
608
2155
|
All column renderers are exported from `@visns-studio/visns-components` and can be used individually or as part of the DataGrid component.
|
|
609
2156
|
|
|
2157
|
+
#### `autoGrow` — rows that outgrow the fixed tablet row height
|
|
2158
|
+
|
|
2159
|
+
In tablet mode (and in `large` density) the DataGrid runs at a **fixed** row
|
|
2160
|
+
height from `DENSITY_GRID` — 52px or 60px — because the alternative, letting
|
|
2161
|
+
the grid measure every row, depends on a rAF that a background-throttled tab
|
|
2162
|
+
never runs, which clipped action buttons after an auto-refresh. The cost is
|
|
2163
|
+
that a cell whose content genuinely needs a second or third line (a
|
|
2164
|
+
`stageCounter` with enough chips to wrap) is cut off at the row height.
|
|
2165
|
+
|
|
2166
|
+
`"autoGrow": true` on a column buys those rows back, and only those rows:
|
|
2167
|
+
|
|
2168
|
+
```jsx
|
|
2169
|
+
{
|
|
2170
|
+
id: 'headers',
|
|
2171
|
+
label: 'Headers',
|
|
2172
|
+
type: 'stageCounter',
|
|
2173
|
+
autoGrow: true,
|
|
2174
|
+
stageConfig: { key: 'headers', stageLabelKey: 'name' }
|
|
2175
|
+
}
|
|
2176
|
+
```
|
|
2177
|
+
|
|
2178
|
+
- The cell's content is measured with a `ResizeObserver`; a row whose content
|
|
2179
|
+
needs more than the fixed height grows to fit it, and every other row keeps
|
|
2180
|
+
the fixed height exactly as before.
|
|
2181
|
+
- The added height is the content's natural height plus the cell's **measured**
|
|
2182
|
+
vertical padding and borders, so it tracks `--grid-cell-padding` and the
|
|
2183
|
+
density in force rather than a constant.
|
|
2184
|
+
- **Desktop density is unaffected** (`rowHeight` is `null` there, so rows
|
|
2185
|
+
already measure naturally) and a grid with no `autoGrow` column takes exactly
|
|
2186
|
+
the code path it did before.
|
|
2187
|
+
- Group rows (`groupBy`) keep the default height; `treeGroupBy` parents and
|
|
2188
|
+
members grow like any other row.
|
|
2189
|
+
- Grown rows stay vertically centred, so neighbouring cells and the action
|
|
2190
|
+
buttons line up as they always have.
|
|
2191
|
+
|
|
610
2192
|
#### Dropdown Column Color Support
|
|
611
2193
|
|
|
612
2194
|
The dropdown column type now supports automatic background color application based on the selected option's `colour` property. This feature works in both DataGrid and GenericEditableTable components.
|
|
@@ -3184,11 +4766,16 @@ This grouping is handled internally by the GenericAuth component, so you don't n
|
|
|
3184
4766
|
### How 2FA Works
|
|
3185
4767
|
|
|
3186
4768
|
1. User enters email and password on the login screen
|
|
3187
|
-
2. If credentials are valid and 2FA is required, the backend returns `
|
|
3188
|
-
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)
|
|
3189
4771
|
4. User enters the verification code
|
|
3190
4772
|
5. If the code is valid, the user is authenticated and redirected to the main application
|
|
3191
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
|
+
|
|
3192
4779
|
### Setting Up 2FA
|
|
3193
4780
|
|
|
3194
4781
|
Users can set up 2FA through their profile page. The setup process includes:
|
|
@@ -3233,12 +4820,28 @@ The login form includes a "Remember Me" checkbox that persists through the 2FA v
|
|
|
3233
4820
|
|
|
3234
4821
|
### API Integration for 2FA
|
|
3235
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
|
+
|
|
3236
4836
|
#### Login Endpoint Response (when 2FA is required)
|
|
3237
4837
|
|
|
4838
|
+
TOTP echoes the user, because its challenge screen renders the account it is
|
|
4839
|
+
challenging:
|
|
4840
|
+
|
|
3238
4841
|
```json
|
|
3239
4842
|
{
|
|
3240
4843
|
"error": "",
|
|
3241
|
-
"
|
|
4844
|
+
"requires_two_factor": true,
|
|
3242
4845
|
"user": {
|
|
3243
4846
|
"id": "user_id",
|
|
3244
4847
|
"email": "user@example.com",
|
|
@@ -3247,14 +4850,44 @@ The login form includes a "Remember Me" checkbox that persists through the 2FA v
|
|
|
3247
4850
|
}
|
|
3248
4851
|
```
|
|
3249
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
|
+
|
|
3250
4882
|
#### 2FA Verification Endpoint
|
|
3251
4883
|
|
|
4884
|
+
`POST /login/two-factor-challenge` (configurable — `endpoints.twoFactorChallenge`)
|
|
4885
|
+
|
|
3252
4886
|
Request:
|
|
3253
4887
|
|
|
3254
4888
|
```json
|
|
3255
4889
|
{
|
|
3256
|
-
"
|
|
3257
|
-
"verification_code": "123456",
|
|
4890
|
+
"code": "123456",
|
|
3258
4891
|
"previous_url": "/optional-redirect-url",
|
|
3259
4892
|
"remember": true
|
|
3260
4893
|
}
|
|
@@ -3262,19 +4895,26 @@ Request:
|
|
|
3262
4895
|
|
|
3263
4896
|
Response:
|
|
3264
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
|
+
|
|
3265
4903
|
```json
|
|
3266
4904
|
{
|
|
3267
|
-
"
|
|
3268
|
-
"
|
|
3269
|
-
|
|
3270
|
-
|
|
3271
|
-
"name": "User Name",
|
|
3272
|
-
"roles": ["admin"]
|
|
3273
|
-
},
|
|
3274
|
-
"previous": "/optional-redirect-url"
|
|
4905
|
+
"id": "user_id",
|
|
4906
|
+
"email": "user@example.com",
|
|
4907
|
+
"name": "User Name",
|
|
4908
|
+
"roles": ["admin"]
|
|
3275
4909
|
}
|
|
3276
4910
|
```
|
|
3277
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
|
+
|
|
3278
4918
|
#### 2FA Setup Endpoint Response
|
|
3279
4919
|
|
|
3280
4920
|
```json
|