@visns-studio/visns-components 6.17.2 → 6.20.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/package.json +10 -7
  2. package/src/components/AssociationManager.jsx +344 -177
  3. package/src/components/AsyncSelect.jsx +2 -2
  4. package/src/components/Autocomplete.jsx +57 -12
  5. package/src/components/Breadcrumb.jsx +23 -4
  6. package/src/components/ClientAssociationManager.jsx +321 -145
  7. package/src/components/DataGrid.jsx +625 -20
  8. package/src/components/DropZone.jsx +4 -1
  9. package/src/components/Form.jsx +233 -6
  10. package/src/components/MultiSelect.jsx +2 -2
  11. package/src/components/Navigation.jsx +39 -23
  12. package/src/components/TableFilter.jsx +172 -12
  13. package/src/components/UniversalSearch.jsx +390 -0
  14. package/src/components/auth/Login.jsx +164 -8
  15. package/src/components/auth/Profile.jsx +209 -22
  16. package/src/components/auth/authEndpoints.js +4 -0
  17. package/src/components/auth/webauthn.js +194 -0
  18. package/src/components/columns/ColumnRenderers.jsx +6 -1
  19. package/src/components/controls/DataGridSearch.jsx +11 -5
  20. package/src/components/generic/GenericAuth.jsx +6 -0
  21. package/src/components/generic/GenericDashboard.jsx +93 -36
  22. package/src/components/generic/GenericDetail.jsx +146 -61
  23. package/src/components/generic/GenericIndex.jsx +142 -38
  24. package/src/components/generic/GenericSettings.jsx +220 -0
  25. package/src/components/generic/StandardModal.jsx +165 -8
  26. package/src/components/generic/dashboardChartDefaults.js +230 -0
  27. package/src/components/generic/shared/indexLayout.js +38 -0
  28. package/src/components/settings/IntegrationsManager.jsx +474 -0
  29. package/src/components/sms/SmsLineSettings.jsx +16 -12
  30. package/src/components/styles/AssociationManager.module.scss +4 -363
  31. package/src/components/styles/AuditLog.module.scss +6 -6
  32. package/src/components/styles/AuditLogs.module.scss +3 -3
  33. package/src/components/styles/Autocomplete.module.scss +7 -7
  34. package/src/components/styles/BusinessCardOcr.module.scss +6 -1
  35. package/src/components/styles/CallQueueSettings.module.scss +4 -1
  36. package/src/components/styles/CameraPlacement.module.scss +19 -19
  37. package/src/components/styles/CategorizedDropZone.module.scss +6 -1
  38. package/src/components/styles/ClientAssociationManager.module.scss +5 -289
  39. package/src/components/styles/ClientAuth.module.scss +1 -1
  40. package/src/components/styles/DataGrid.module.scss +455 -8
  41. package/src/components/styles/Field.module.scss +32 -24
  42. package/src/components/styles/Form.module.scss +123 -3
  43. package/src/components/styles/GenericDashboard.module.scss +612 -825
  44. package/src/components/styles/GenericDetail.module.scss +406 -61
  45. package/src/components/styles/GenericDynamic.module.scss +25 -25
  46. package/src/components/styles/GenericEditableTable.module.scss +33 -33
  47. package/src/components/styles/GenericFormBuilder.module.scss +119 -119
  48. package/src/components/styles/GenericIndex.module.scss +483 -38
  49. package/src/components/styles/GenericMain.module.scss +6 -3
  50. package/src/components/styles/GenericQuote.module.scss +399 -81
  51. package/src/components/styles/GenericReport.module.scss +8 -8
  52. package/src/components/styles/GenericSettings.module.scss +209 -0
  53. package/src/components/styles/GenericSort.module.scss +9 -9
  54. package/src/components/styles/Integrations.module.scss +321 -0
  55. package/src/components/styles/List.module.scss +4 -4
  56. package/src/components/styles/Login.module.scss +57 -0
  57. package/src/components/styles/MultiSelect.module.scss +4 -4
  58. package/src/components/styles/Navigation.module.scss +12 -12
  59. package/src/components/styles/Notification.module.scss +12 -8
  60. package/src/components/styles/NotificationList.module.scss +5 -5
  61. package/src/components/styles/Profile.module.scss +5 -5
  62. package/src/components/styles/QrCode.module.scss +4 -4
  63. package/src/components/styles/QuickAction.module.scss +20 -20
  64. package/src/components/styles/SortableQuoteItems.module.scss +4 -4
  65. package/src/components/styles/TableFilter.module.scss +128 -17
  66. package/src/components/styles/TicketConversation.module.scss +497 -0
  67. package/src/components/styles/UniversalSearch.module.scss +229 -0
  68. package/src/components/styles/Vault.module.scss +413 -0
  69. package/src/components/styles/_associationManager.scss +467 -0
  70. package/src/components/styles/_authBrandPanel.scss +15 -9
  71. package/src/components/styles/_controls.scss +43 -39
  72. package/src/components/styles/global-datagrid.css +196 -0
  73. package/src/components/styles/global.css +168 -17
  74. package/src/components/tickets/TicketConversation.jsx +455 -0
  75. package/src/components/utils/rowActionSettings.js +117 -0
  76. package/src/components/vault/VaultClientPicker.jsx +183 -0
  77. package/src/components/vault/VaultEntryForm.jsx +23 -0
  78. package/src/components/vault/VaultManager.jsx +382 -34
  79. package/src/components/vault/VaultShareModal.jsx +454 -0
  80. package/src/components/vault/vaultEndpoints.js +16 -2
  81. package/src/components/vault/vaultShares.js +247 -0
  82. package/src/index.js +35 -0
  83. 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
- const [showEmail, setShowEmail] = useState(!ssoAvailable);
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 && ssoAvailable) {
117
+ if (showEmail && hasAlternative) {
89
118
  emailRef.current?.focus();
90
119
  }
91
- }, [showEmail, ssoAvailable]);
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
- : 'Sign in with your email and password.')}
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
- {ssoAvailable && !showEmail && (
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 && ssoAvailable && (
495
+ {showEmail && hasAlternative && (
340
496
  <div className={styles.divider}>
341
497
  <span>{copy.divider}</span>
342
498
  </div>