@visns-studio/visns-components 6.17.2 → 6.20.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/package.json +10 -7
- package/src/components/AssociationManager.jsx +344 -177
- package/src/components/AsyncSelect.jsx +2 -2
- package/src/components/Autocomplete.jsx +57 -12
- package/src/components/Breadcrumb.jsx +23 -4
- package/src/components/ClientAssociationManager.jsx +321 -145
- package/src/components/DataGrid.jsx +625 -20
- package/src/components/DropZone.jsx +4 -1
- package/src/components/Form.jsx +233 -6
- package/src/components/MultiSelect.jsx +2 -2
- package/src/components/Navigation.jsx +39 -23
- package/src/components/TableFilter.jsx +172 -12
- package/src/components/UniversalSearch.jsx +390 -0
- package/src/components/auth/Login.jsx +164 -8
- package/src/components/auth/Profile.jsx +209 -22
- package/src/components/auth/authEndpoints.js +4 -0
- package/src/components/auth/webauthn.js +194 -0
- package/src/components/columns/ColumnRenderers.jsx +6 -1
- package/src/components/controls/DataGridSearch.jsx +11 -5
- package/src/components/generic/GenericAuth.jsx +6 -0
- package/src/components/generic/GenericDashboard.jsx +93 -36
- package/src/components/generic/GenericDetail.jsx +146 -61
- package/src/components/generic/GenericIndex.jsx +134 -38
- package/src/components/generic/GenericSettings.jsx +220 -0
- package/src/components/generic/StandardModal.jsx +165 -8
- package/src/components/generic/dashboardChartDefaults.js +230 -0
- package/src/components/generic/shared/indexLayout.js +38 -0
- package/src/components/settings/IntegrationsManager.jsx +474 -0
- package/src/components/sms/SmsLineSettings.jsx +16 -12
- package/src/components/styles/AssociationManager.module.scss +4 -363
- package/src/components/styles/AuditLog.module.scss +6 -6
- package/src/components/styles/AuditLogs.module.scss +3 -3
- package/src/components/styles/Autocomplete.module.scss +7 -7
- package/src/components/styles/BusinessCardOcr.module.scss +6 -1
- package/src/components/styles/CallQueueSettings.module.scss +4 -1
- package/src/components/styles/CameraPlacement.module.scss +19 -19
- package/src/components/styles/CategorizedDropZone.module.scss +6 -1
- package/src/components/styles/ClientAssociationManager.module.scss +5 -289
- package/src/components/styles/ClientAuth.module.scss +1 -1
- package/src/components/styles/DataGrid.module.scss +455 -8
- package/src/components/styles/Field.module.scss +32 -24
- package/src/components/styles/Form.module.scss +123 -3
- package/src/components/styles/GenericDashboard.module.scss +612 -825
- package/src/components/styles/GenericDetail.module.scss +406 -61
- package/src/components/styles/GenericDynamic.module.scss +25 -25
- package/src/components/styles/GenericEditableTable.module.scss +33 -33
- package/src/components/styles/GenericFormBuilder.module.scss +119 -119
- package/src/components/styles/GenericIndex.module.scss +483 -38
- package/src/components/styles/GenericMain.module.scss +6 -3
- package/src/components/styles/GenericQuote.module.scss +399 -81
- package/src/components/styles/GenericReport.module.scss +8 -8
- package/src/components/styles/GenericSettings.module.scss +209 -0
- package/src/components/styles/GenericSort.module.scss +9 -9
- package/src/components/styles/Integrations.module.scss +321 -0
- package/src/components/styles/List.module.scss +4 -4
- package/src/components/styles/Login.module.scss +57 -0
- package/src/components/styles/MultiSelect.module.scss +4 -4
- package/src/components/styles/Navigation.module.scss +12 -12
- package/src/components/styles/Notification.module.scss +12 -8
- package/src/components/styles/NotificationList.module.scss +5 -5
- package/src/components/styles/Profile.module.scss +5 -5
- package/src/components/styles/QrCode.module.scss +4 -4
- package/src/components/styles/QuickAction.module.scss +20 -20
- package/src/components/styles/SortableQuoteItems.module.scss +4 -4
- package/src/components/styles/TableFilter.module.scss +128 -17
- package/src/components/styles/TicketConversation.module.scss +497 -0
- package/src/components/styles/UniversalSearch.module.scss +229 -0
- package/src/components/styles/Vault.module.scss +413 -0
- package/src/components/styles/_associationManager.scss +467 -0
- package/src/components/styles/_authBrandPanel.scss +15 -9
- package/src/components/styles/_controls.scss +43 -39
- package/src/components/styles/global-datagrid.css +196 -0
- package/src/components/styles/global.css +168 -17
- package/src/components/tickets/TicketConversation.jsx +455 -0
- package/src/components/utils/rowActionSettings.js +117 -0
- package/src/components/vault/VaultClientPicker.jsx +183 -0
- package/src/components/vault/VaultEntryForm.jsx +23 -0
- package/src/components/vault/VaultManager.jsx +382 -34
- package/src/components/vault/VaultShareModal.jsx +454 -0
- package/src/components/vault/vaultEndpoints.js +16 -2
- package/src/components/vault/vaultShares.js +247 -0
- package/src/index.js +35 -0
- package/src/utils/fetchUtil.js +38 -14
|
@@ -0,0 +1,390 @@
|
|
|
1
|
+
import React, {
|
|
2
|
+
useCallback,
|
|
3
|
+
useEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
} from 'react';
|
|
8
|
+
import { useNavigate } from 'react-router-dom';
|
|
9
|
+
import { Search, CornerDownLeft } from 'lucide-react';
|
|
10
|
+
|
|
11
|
+
import CustomFetch from './Fetch';
|
|
12
|
+
import styles from './styles/UniversalSearch.module.scss';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* One search box for the whole application.
|
|
16
|
+
*
|
|
17
|
+
* Mounts as an account action through Navigation's `renderers` slot, the same
|
|
18
|
+
* way VaultQuickSearch and SmsInboxBadge do, so the three chips read as one
|
|
19
|
+
* row. ⌘K (Ctrl+K off a Mac) opens it from anywhere.
|
|
20
|
+
*
|
|
21
|
+
* The backend decides what is searched AND what this user may see — a source
|
|
22
|
+
* they lack the permission for is never queried, so an empty group here means
|
|
23
|
+
* "nothing matched", never "you are not allowed". Nothing in this component
|
|
24
|
+
* knows the model list.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
const isMac = () =>
|
|
28
|
+
typeof navigator !== 'undefined' &&
|
|
29
|
+
/Mac|iPhone|iPad/.test(navigator.platform || '');
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_UNIVERSAL_SEARCH_ENDPOINT = '/ajax/search';
|
|
32
|
+
|
|
33
|
+
const UniversalSearch = ({
|
|
34
|
+
setting,
|
|
35
|
+
endpoint = DEFAULT_UNIVERSAL_SEARCH_ENDPOINT,
|
|
36
|
+
placeholder = 'Search customers, tickets, quotes…',
|
|
37
|
+
minLength = 2,
|
|
38
|
+
debounceMs = 220,
|
|
39
|
+
navigate: navigateProp = null,
|
|
40
|
+
}) => {
|
|
41
|
+
const [open, setOpen] = useState(false);
|
|
42
|
+
const [term, setTerm] = useState('');
|
|
43
|
+
const [groups, setGroups] = useState([]);
|
|
44
|
+
const [loading, setLoading] = useState(false);
|
|
45
|
+
const [message, setMessage] = useState(null);
|
|
46
|
+
const [active, setActive] = useState(0);
|
|
47
|
+
|
|
48
|
+
const inputRef = useRef(null);
|
|
49
|
+
const panelRef = useRef(null);
|
|
50
|
+
// Every in-flight request carries a ticket; only the newest may write
|
|
51
|
+
// state. Without it a slow "ac" can land after a fast "acme" and replace
|
|
52
|
+
// the right answer with a stale one.
|
|
53
|
+
const ticket = useRef(0);
|
|
54
|
+
|
|
55
|
+
let routerNavigate = null;
|
|
56
|
+
try {
|
|
57
|
+
routerNavigate = useNavigate();
|
|
58
|
+
} catch {
|
|
59
|
+
routerNavigate = null; // mounted outside a Router
|
|
60
|
+
}
|
|
61
|
+
const go = navigateProp || routerNavigate;
|
|
62
|
+
|
|
63
|
+
// A flat list of every result, so ↑/↓ can cross group boundaries.
|
|
64
|
+
const flat = useMemo(
|
|
65
|
+
() =>
|
|
66
|
+
groups.flatMap((g) =>
|
|
67
|
+
g.results.map((r) => ({ ...r, group: g.label }))
|
|
68
|
+
),
|
|
69
|
+
[groups]
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
// -- fetching ------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
useEffect(() => {
|
|
75
|
+
if (!open) return undefined;
|
|
76
|
+
|
|
77
|
+
const q = term.trim();
|
|
78
|
+
|
|
79
|
+
if (q.length < minLength) {
|
|
80
|
+
setGroups([]);
|
|
81
|
+
setMessage(null);
|
|
82
|
+
setLoading(false);
|
|
83
|
+
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const mine = ++ticket.current;
|
|
88
|
+
setLoading(true);
|
|
89
|
+
|
|
90
|
+
const timer = setTimeout(async () => {
|
|
91
|
+
try {
|
|
92
|
+
// The query goes as the third argument rather than inline in
|
|
93
|
+
// the URL: CustomFetch builds the query string itself for GET,
|
|
94
|
+
// and its response cache keys on (url, method, params).
|
|
95
|
+
const res = await CustomFetch(endpoint, 'GET', { q });
|
|
96
|
+
|
|
97
|
+
if (mine !== ticket.current) return; // a newer query won
|
|
98
|
+
|
|
99
|
+
// Consumers wrap responses differently — some return the body,
|
|
100
|
+
// some `{ data: body }`. Unwrap rather than assume.
|
|
101
|
+
const body = res?.groups ? res : (res?.data ?? res);
|
|
102
|
+
|
|
103
|
+
setGroups(Array.isArray(body?.groups) ? body.groups : []);
|
|
104
|
+
setMessage(body?.message ?? null);
|
|
105
|
+
setActive(0);
|
|
106
|
+
} catch {
|
|
107
|
+
if (mine !== ticket.current) return;
|
|
108
|
+
setGroups([]);
|
|
109
|
+
setMessage('Search is unavailable right now.');
|
|
110
|
+
} finally {
|
|
111
|
+
if (mine === ticket.current) setLoading(false);
|
|
112
|
+
}
|
|
113
|
+
}, debounceMs);
|
|
114
|
+
|
|
115
|
+
return () => clearTimeout(timer);
|
|
116
|
+
}, [term, open, endpoint, minLength, debounceMs]);
|
|
117
|
+
|
|
118
|
+
// -- opening -------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
const close = useCallback(() => {
|
|
121
|
+
setOpen(false);
|
|
122
|
+
setTerm('');
|
|
123
|
+
setGroups([]);
|
|
124
|
+
setActive(0);
|
|
125
|
+
}, []);
|
|
126
|
+
|
|
127
|
+
useEffect(() => {
|
|
128
|
+
/**
|
|
129
|
+
* Capture phase on purpose: the CRM is full of controls that stop
|
|
130
|
+
* keydown propagating — the data grid, the rich text editors,
|
|
131
|
+
* react-select — and a bubbling listener behind any of them never
|
|
132
|
+
* hears the key. Same reasoning as VaultQuickSearch's shortcut.
|
|
133
|
+
*/
|
|
134
|
+
const onKey = (e) => {
|
|
135
|
+
const mod = isMac() ? e.metaKey : e.ctrlKey;
|
|
136
|
+
|
|
137
|
+
if (mod && e.key.toLowerCase() === 'k') {
|
|
138
|
+
// An editor's own ⌘K (insert link) has the better claim while
|
|
139
|
+
// the caret is in rich text.
|
|
140
|
+
const el = document.activeElement;
|
|
141
|
+
if (el?.isContentEditable) return;
|
|
142
|
+
|
|
143
|
+
e.preventDefault();
|
|
144
|
+
setOpen((v) => !v);
|
|
145
|
+
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (e.key === 'Escape' && open) {
|
|
150
|
+
e.preventDefault();
|
|
151
|
+
close();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
window.addEventListener('keydown', onKey, true);
|
|
156
|
+
|
|
157
|
+
return () => window.removeEventListener('keydown', onKey, true);
|
|
158
|
+
}, [open, close]);
|
|
159
|
+
|
|
160
|
+
useEffect(() => {
|
|
161
|
+
if (open) {
|
|
162
|
+
// Focus after paint, or the input is not in the DOM yet.
|
|
163
|
+
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
|
164
|
+
|
|
165
|
+
return () => clearTimeout(t);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return undefined;
|
|
169
|
+
}, [open]);
|
|
170
|
+
|
|
171
|
+
useEffect(() => {
|
|
172
|
+
if (!open) return undefined;
|
|
173
|
+
|
|
174
|
+
const onDown = (e) => {
|
|
175
|
+
if (panelRef.current && !panelRef.current.contains(e.target))
|
|
176
|
+
close();
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
document.addEventListener('mousedown', onDown);
|
|
180
|
+
|
|
181
|
+
return () => document.removeEventListener('mousedown', onDown);
|
|
182
|
+
}, [open, close]);
|
|
183
|
+
|
|
184
|
+
// -- choosing ------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
const choose = useCallback(
|
|
187
|
+
(result) => {
|
|
188
|
+
if (!result?.url) return;
|
|
189
|
+
|
|
190
|
+
close();
|
|
191
|
+
|
|
192
|
+
if (go) {
|
|
193
|
+
go(result.url);
|
|
194
|
+
} else {
|
|
195
|
+
window.location.href = result.url;
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
[go, close]
|
|
199
|
+
);
|
|
200
|
+
|
|
201
|
+
const onInputKey = (e) => {
|
|
202
|
+
if (flat.length === 0) return;
|
|
203
|
+
|
|
204
|
+
if (e.key === 'ArrowDown') {
|
|
205
|
+
e.preventDefault();
|
|
206
|
+
setActive((i) => (i + 1) % flat.length);
|
|
207
|
+
} else if (e.key === 'ArrowUp') {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
setActive((i) => (i - 1 + flat.length) % flat.length);
|
|
210
|
+
} else if (e.key === 'Enter') {
|
|
211
|
+
e.preventDefault();
|
|
212
|
+
choose(flat[active]);
|
|
213
|
+
}
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
const shortcut = isMac() ? '⌘K' : 'Ctrl+K';
|
|
217
|
+
const label = setting?.label || 'Search';
|
|
218
|
+
|
|
219
|
+
let flatIndex = -1;
|
|
220
|
+
|
|
221
|
+
return (
|
|
222
|
+
<div className={styles.wrap}>
|
|
223
|
+
<button
|
|
224
|
+
type="button"
|
|
225
|
+
className={styles.trigger}
|
|
226
|
+
onClick={() => setOpen((v) => !v)}
|
|
227
|
+
aria-label={`${label} (${shortcut})`}
|
|
228
|
+
data-tooltip-content={`${label} (${shortcut})`}
|
|
229
|
+
aria-expanded={open}
|
|
230
|
+
aria-haspopup="dialog"
|
|
231
|
+
>
|
|
232
|
+
<Search size={18} strokeWidth={2} aria-hidden="true" />
|
|
233
|
+
</button>
|
|
234
|
+
|
|
235
|
+
{open && (
|
|
236
|
+
// The host's `.hactions` is a horizontal scroll container, so it
|
|
237
|
+
// clips on both axes; this attribute is the contract that tells
|
|
238
|
+
// it to let the panel out. Same as Notification and the vault.
|
|
239
|
+
<div data-nav-overlay="open" className={styles.overlay}>
|
|
240
|
+
<div
|
|
241
|
+
ref={panelRef}
|
|
242
|
+
className={styles.panel}
|
|
243
|
+
role="dialog"
|
|
244
|
+
aria-modal="false"
|
|
245
|
+
aria-label={label}
|
|
246
|
+
>
|
|
247
|
+
<div className={styles.field}>
|
|
248
|
+
<Search
|
|
249
|
+
size={16}
|
|
250
|
+
aria-hidden="true"
|
|
251
|
+
className={styles.fieldIcon}
|
|
252
|
+
/>
|
|
253
|
+
<input
|
|
254
|
+
ref={inputRef}
|
|
255
|
+
type="text"
|
|
256
|
+
className={styles.input}
|
|
257
|
+
value={term}
|
|
258
|
+
placeholder={placeholder}
|
|
259
|
+
onChange={(e) => setTerm(e.target.value)}
|
|
260
|
+
onKeyDown={onInputKey}
|
|
261
|
+
aria-label={placeholder}
|
|
262
|
+
autoComplete="off"
|
|
263
|
+
spellCheck="false"
|
|
264
|
+
/>
|
|
265
|
+
<kbd className={styles.kbd}>esc</kbd>
|
|
266
|
+
</div>
|
|
267
|
+
|
|
268
|
+
<div className={styles.results}>
|
|
269
|
+
{loading && (
|
|
270
|
+
<p className={styles.state}>Searching…</p>
|
|
271
|
+
)}
|
|
272
|
+
|
|
273
|
+
{!loading && message && (
|
|
274
|
+
<p className={styles.state}>{message}</p>
|
|
275
|
+
)}
|
|
276
|
+
|
|
277
|
+
{!loading &&
|
|
278
|
+
!message &&
|
|
279
|
+
term.trim().length >= minLength &&
|
|
280
|
+
groups.length === 0 && (
|
|
281
|
+
<p className={styles.state}>
|
|
282
|
+
Nothing matched “{term.trim()}”.
|
|
283
|
+
</p>
|
|
284
|
+
)}
|
|
285
|
+
|
|
286
|
+
{!loading && term.trim().length < minLength && (
|
|
287
|
+
<p className={styles.state}>
|
|
288
|
+
Type at least {minLength} characters.
|
|
289
|
+
</p>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{!loading &&
|
|
293
|
+
groups.map((group) => (
|
|
294
|
+
<section
|
|
295
|
+
key={group.key}
|
|
296
|
+
className={styles.group}
|
|
297
|
+
>
|
|
298
|
+
<h3 className={styles.groupLabel}>
|
|
299
|
+
{group.label}
|
|
300
|
+
</h3>
|
|
301
|
+
<ul className={styles.rows}>
|
|
302
|
+
{group.results.map((result) => {
|
|
303
|
+
flatIndex += 1;
|
|
304
|
+
const isActive =
|
|
305
|
+
flatIndex === active;
|
|
306
|
+
const myIndex = flatIndex;
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<li
|
|
310
|
+
key={`${group.key}-${result.id}`}
|
|
311
|
+
>
|
|
312
|
+
<button
|
|
313
|
+
type="button"
|
|
314
|
+
className={`${styles.row} ${
|
|
315
|
+
isActive
|
|
316
|
+
? styles.rowActive
|
|
317
|
+
: ''
|
|
318
|
+
}`}
|
|
319
|
+
onClick={() =>
|
|
320
|
+
choose(result)
|
|
321
|
+
}
|
|
322
|
+
onMouseEnter={() =>
|
|
323
|
+
setActive(
|
|
324
|
+
myIndex
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
title={`${result.title}${
|
|
328
|
+
result.subtitle
|
|
329
|
+
? ' — ' +
|
|
330
|
+
result.subtitle
|
|
331
|
+
: ''
|
|
332
|
+
}`}
|
|
333
|
+
>
|
|
334
|
+
<span
|
|
335
|
+
className={
|
|
336
|
+
styles.rowTitle
|
|
337
|
+
}
|
|
338
|
+
>
|
|
339
|
+
{result.title}
|
|
340
|
+
</span>
|
|
341
|
+
{result.subtitle && (
|
|
342
|
+
<span
|
|
343
|
+
className={
|
|
344
|
+
styles.rowSub
|
|
345
|
+
}
|
|
346
|
+
>
|
|
347
|
+
{
|
|
348
|
+
result.subtitle
|
|
349
|
+
}
|
|
350
|
+
</span>
|
|
351
|
+
)}
|
|
352
|
+
{isActive && (
|
|
353
|
+
<CornerDownLeft
|
|
354
|
+
size={13}
|
|
355
|
+
aria-hidden="true"
|
|
356
|
+
className={
|
|
357
|
+
styles.rowEnter
|
|
358
|
+
}
|
|
359
|
+
/>
|
|
360
|
+
)}
|
|
361
|
+
</button>
|
|
362
|
+
</li>
|
|
363
|
+
);
|
|
364
|
+
})}
|
|
365
|
+
</ul>
|
|
366
|
+
</section>
|
|
367
|
+
))}
|
|
368
|
+
</div>
|
|
369
|
+
|
|
370
|
+
<footer className={styles.foot}>
|
|
371
|
+
<span>
|
|
372
|
+
<kbd className={styles.kbd}>↑</kbd>
|
|
373
|
+
<kbd className={styles.kbd}>↓</kbd> move
|
|
374
|
+
</span>
|
|
375
|
+
<span>
|
|
376
|
+
<kbd className={styles.kbd}>↵</kbd> open
|
|
377
|
+
</span>
|
|
378
|
+
<span>
|
|
379
|
+
<kbd className={styles.kbd}>{shortcut}</kbd>{' '}
|
|
380
|
+
toggle
|
|
381
|
+
</span>
|
|
382
|
+
</footer>
|
|
383
|
+
</div>
|
|
384
|
+
</div>
|
|
385
|
+
)}
|
|
386
|
+
</div>
|
|
387
|
+
);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
export default UniversalSearch;
|
|
@@ -5,13 +5,18 @@ import './authFont';
|
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react';
|
|
6
6
|
import { Link, useLocation, useNavigate } from 'react-router-dom';
|
|
7
7
|
import { toast } from 'react-toastify';
|
|
8
|
-
import { Eye, EyeOff } from 'lucide-react';
|
|
8
|
+
import { Eye, EyeOff, KeyRound } from 'lucide-react';
|
|
9
9
|
|
|
10
10
|
import CustomFetch from '../Fetch';
|
|
11
11
|
import AuthLoading from './AuthLoading';
|
|
12
12
|
import AuthBrandPanel from './AuthBrandPanel';
|
|
13
13
|
import { resolveAuthEndpoints } from './authEndpoints';
|
|
14
14
|
import { syncCsrfFromResponse } from './authHelpers';
|
|
15
|
+
import {
|
|
16
|
+
getPasskeyCredential,
|
|
17
|
+
isPasskeyCancellation,
|
|
18
|
+
isPasskeySupported,
|
|
19
|
+
} from './webauthn';
|
|
15
20
|
|
|
16
21
|
import styles from '../styles/Login.module.scss';
|
|
17
22
|
|
|
@@ -24,6 +29,8 @@ const DEFAULT_COPY = {
|
|
|
24
29
|
title: 'Sign in',
|
|
25
30
|
sso: 'Sign in with Microsoft',
|
|
26
31
|
ssoRedirecting: 'Redirecting…',
|
|
32
|
+
passkey: 'Sign in with a passkey',
|
|
33
|
+
passkeyPending: 'Waiting for your passkey…',
|
|
27
34
|
altToggle: 'Sign in with email instead',
|
|
28
35
|
divider: 'or sign in with email',
|
|
29
36
|
emailLabel: 'Email',
|
|
@@ -40,6 +47,11 @@ const Login = ({
|
|
|
40
47
|
logo,
|
|
41
48
|
loginBg,
|
|
42
49
|
providers,
|
|
50
|
+
// Whether the host offers passkeys. Config-driven exactly like
|
|
51
|
+
// `providers`: the server says whether the feature exists at all, and
|
|
52
|
+
// this screen additionally checks the browser can do WebAuthn before
|
|
53
|
+
// drawing anything.
|
|
54
|
+
passkeys = false,
|
|
43
55
|
setSystemAuth,
|
|
44
56
|
setUserProfile,
|
|
45
57
|
config = {},
|
|
@@ -70,25 +82,42 @@ const Login = ({
|
|
|
70
82
|
// redirect, and a `previous` URL after sign-in. Never lowered: there is
|
|
71
83
|
// nothing to come back to.
|
|
72
84
|
const [isLeaving, setIsLeaving] = useState(false);
|
|
85
|
+
// The passkey ceremony: the browser's own sheet is up and the
|
|
86
|
+
// authenticator is waiting to be touched. Its own flag rather than
|
|
87
|
+
// `isLoading`, because the button that started it has a label of its own
|
|
88
|
+
// to show while it runs.
|
|
89
|
+
const [isPasskeyPending, setIsPasskeyPending] = useState(false);
|
|
73
90
|
// Any request in flight, or the page on its way out. Every control on the
|
|
74
91
|
// screen keys off this, so a second submit is impossible during either.
|
|
75
|
-
const isBusy = isLoading || isLeaving;
|
|
92
|
+
const isBusy = isLoading || isLeaving || isPasskeyPending;
|
|
76
93
|
const [showPassword, setShowPassword] = useState(false);
|
|
77
94
|
|
|
78
95
|
// Collapsed behind a button, since Microsoft is how nearly everyone signs
|
|
79
96
|
// in. Open from the start when there is no SSO provider configured —
|
|
80
97
|
// otherwise the only way in would be hidden behind a disclosure.
|
|
81
98
|
const ssoAvailable = Array.isArray(providers) && providers.length > 0;
|
|
82
|
-
|
|
99
|
+
// Two conditions, deliberately: the host has to offer passkeys AND the
|
|
100
|
+
// browser has to be able to do them. Read once — a browser does not grow
|
|
101
|
+
// WebAuthn support mid-session, and re-checking on every render would
|
|
102
|
+
// make this a moving target for the collapse logic below.
|
|
103
|
+
const [passkeyAvailable] = useState(
|
|
104
|
+
() => !!passkeys && isPasskeySupported()
|
|
105
|
+
);
|
|
106
|
+
// The email form stays open when it is the only way in. Either
|
|
107
|
+
// alternative being present is enough to fold it away.
|
|
108
|
+
const [showEmail, setShowEmail] = useState(
|
|
109
|
+
!ssoAvailable && !passkeyAvailable
|
|
110
|
+
);
|
|
111
|
+
const hasAlternative = ssoAvailable || passkeyAvailable;
|
|
83
112
|
const emailRef = useRef(null);
|
|
84
113
|
|
|
85
114
|
// Opening the form should put the cursor where typing starts, rather than
|
|
86
115
|
// leaving it on the button that opened it.
|
|
87
116
|
useEffect(() => {
|
|
88
|
-
if (showEmail &&
|
|
117
|
+
if (showEmail && hasAlternative) {
|
|
89
118
|
emailRef.current?.focus();
|
|
90
119
|
}
|
|
91
|
-
}, [showEmail,
|
|
120
|
+
}, [showEmail, hasAlternative]);
|
|
92
121
|
|
|
93
122
|
const [auth, setAuth] = useState({
|
|
94
123
|
email: '',
|
|
@@ -102,6 +131,39 @@ const Login = ({
|
|
|
102
131
|
password: '',
|
|
103
132
|
});
|
|
104
133
|
|
|
134
|
+
// A failed SSO round trip returns as a full page load, not a fetch — there
|
|
135
|
+
// is no response for the backend to put a message in, so it puts one in
|
|
136
|
+
// `?error=`. Captured on the first render (GenericAuth's profile check can
|
|
137
|
+
// navigate this screen and drop the query string a moment later) and
|
|
138
|
+
// raised through the same toast every other failure on this screen uses.
|
|
139
|
+
const [entryError] = useState(() => {
|
|
140
|
+
if (typeof window === 'undefined') {
|
|
141
|
+
return '';
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return new URLSearchParams(window.location.search).get('error') || '';
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
useEffect(() => {
|
|
148
|
+
if (!entryError) {
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Longer than the default: "no account for you, ask an administrator"
|
|
153
|
+
// is a sentence to act on, not a status blip.
|
|
154
|
+
toast.error(entryError, { autoClose: 10000 });
|
|
155
|
+
|
|
156
|
+
// Strip it, so a reload does not replay a message about an attempt
|
|
157
|
+
// that is over. `state` is carried across — a bounce from a protected
|
|
158
|
+
// page parks the intended destination there.
|
|
159
|
+
navigate(location.pathname, {
|
|
160
|
+
replace: true,
|
|
161
|
+
state: location.state,
|
|
162
|
+
});
|
|
163
|
+
// Once, for the message the page arrived carrying.
|
|
164
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
165
|
+
}, []);
|
|
166
|
+
|
|
105
167
|
const renderSsoButton = () => {
|
|
106
168
|
if (!providers || providers.length === 0) {
|
|
107
169
|
return null;
|
|
@@ -156,6 +218,75 @@ const Login = ({
|
|
|
156
218
|
return content;
|
|
157
219
|
};
|
|
158
220
|
|
|
221
|
+
/**
|
|
222
|
+
* CustomFetch as a plain promise, with its automatic error toast off.
|
|
223
|
+
*
|
|
224
|
+
* The passkey flow is three steps that only make sense in order, and a
|
|
225
|
+
* failure part way through needs one message written by the code below —
|
|
226
|
+
* not the raw server string the default handler would raise.
|
|
227
|
+
*/
|
|
228
|
+
const postJson = (url, body) =>
|
|
229
|
+
new Promise((resolve, reject) => {
|
|
230
|
+
CustomFetch(url, 'POST', body, resolve, (message) =>
|
|
231
|
+
reject(new Error(message || 'Request failed.'))
|
|
232
|
+
);
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Sign in with a passkey.
|
|
237
|
+
*
|
|
238
|
+
* Challenge from the server, signed by the authenticator, back to the
|
|
239
|
+
* server. No email is asked for and none is sent: the credential is
|
|
240
|
+
* discoverable, so it carries the identity itself.
|
|
241
|
+
*/
|
|
242
|
+
const handlePasskey = async () => {
|
|
243
|
+
if (isBusy) {
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
setIsPasskeyPending(true);
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
const options = await postJson(urls.passkeyLoginOptions, {});
|
|
251
|
+
const assertion = await getPasskeyCredential(options);
|
|
252
|
+
const result = await postJson(urls.passkeyLogin, {
|
|
253
|
+
...assertion,
|
|
254
|
+
location: location.state?.previousUrl || '',
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
// Same response shape as the email form's, and handled the same
|
|
258
|
+
// way — see handleSubmit.
|
|
259
|
+
syncCsrfFromResponse(result);
|
|
260
|
+
setUserProfile(result.user);
|
|
261
|
+
|
|
262
|
+
if (result.previous && result.previous !== '') {
|
|
263
|
+
setIsLeaving(true);
|
|
264
|
+
window.location.href = result.previous;
|
|
265
|
+
} else {
|
|
266
|
+
// Stays pending: GenericAuth's curtain takes over from here,
|
|
267
|
+
// and a button snapping back to idle underneath it reads as
|
|
268
|
+
// "nothing happened".
|
|
269
|
+
setSystemAuth(true);
|
|
270
|
+
}
|
|
271
|
+
} catch (error) {
|
|
272
|
+
setIsPasskeyPending(false);
|
|
273
|
+
|
|
274
|
+
// Dismissing the browser's sheet is not a failure. The person
|
|
275
|
+
// just decided not to; telling them so in red is noise.
|
|
276
|
+
if (isPasskeyCancellation(error)) {
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
toast.error(
|
|
281
|
+
error?.message ||
|
|
282
|
+
'That passkey could not be used. Please try again, or sign in with your email.'
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
// Leave a way forward on screen rather than only a red toast.
|
|
286
|
+
setShowEmail(true);
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
159
290
|
const handleChange = (e) => {
|
|
160
291
|
if (e) {
|
|
161
292
|
const value =
|
|
@@ -317,14 +448,39 @@ const Login = ({
|
|
|
317
448
|
{branding.signInHint ||
|
|
318
449
|
(ssoAvailable
|
|
319
450
|
? 'Use your Microsoft account to continue.'
|
|
320
|
-
:
|
|
451
|
+
: passkeyAvailable
|
|
452
|
+
? 'Use your passkey to continue.'
|
|
453
|
+
: 'Sign in with your email and password.')}
|
|
321
454
|
</p>
|
|
322
455
|
|
|
323
456
|
{/* SSO leads when configured; the email form stays
|
|
324
457
|
available underneath for accounts without it. */}
|
|
325
458
|
{renderSsoButton()}
|
|
326
459
|
|
|
327
|
-
{
|
|
460
|
+
{/* Then the passkey, for the people who have set one up.
|
|
461
|
+
Below Microsoft rather than above it: SSO is how most
|
|
462
|
+
of this company signs in, and a passkey is something a
|
|
463
|
+
person opted into for themselves. */}
|
|
464
|
+
{passkeyAvailable && (
|
|
465
|
+
<button
|
|
466
|
+
type="button"
|
|
467
|
+
className={styles.passkey}
|
|
468
|
+
disabled={isBusy}
|
|
469
|
+
onClick={(e) => {
|
|
470
|
+
e.preventDefault();
|
|
471
|
+
handlePasskey();
|
|
472
|
+
}}
|
|
473
|
+
>
|
|
474
|
+
<span className={styles.passkeyMark}>
|
|
475
|
+
<KeyRound size={18} strokeWidth={1.9} />
|
|
476
|
+
</span>
|
|
477
|
+
{isPasskeyPending
|
|
478
|
+
? copy.passkeyPending
|
|
479
|
+
: copy.passkey}
|
|
480
|
+
</button>
|
|
481
|
+
)}
|
|
482
|
+
|
|
483
|
+
{hasAlternative && !showEmail && (
|
|
328
484
|
<button
|
|
329
485
|
type="button"
|
|
330
486
|
className={styles.altToggle}
|
|
@@ -336,7 +492,7 @@ const Login = ({
|
|
|
336
492
|
</button>
|
|
337
493
|
)}
|
|
338
494
|
|
|
339
|
-
{showEmail &&
|
|
495
|
+
{showEmail && hasAlternative && (
|
|
340
496
|
<div className={styles.divider}>
|
|
341
497
|
<span>{copy.divider}</span>
|
|
342
498
|
</div>
|