@visns-studio/visns-components 6.32.2 → 6.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +504 -0
  2. package/package.json +1 -1
  3. package/src/components/DataGrid.jsx +44 -2
  4. package/src/components/Field.jsx +32 -0
  5. package/src/components/Form.jsx +39 -2
  6. package/src/components/Navigation.jsx +353 -43
  7. package/src/components/Notification.jsx +13 -2
  8. package/src/components/TableFilter.jsx +14 -2
  9. package/src/components/auth/Login.jsx +9 -2
  10. package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
  11. package/src/components/auth/Profile.jsx +422 -26
  12. package/src/components/auth/authEndpoints.js +43 -0
  13. package/src/components/auth/passkeyClient.js +102 -0
  14. package/src/components/auth/passkeyPrompt.js +408 -0
  15. package/src/components/auth/profileLayout.js +45 -0
  16. package/src/components/controls/DataGridSearch.jsx +16 -2
  17. package/src/components/controls/DataGridSortSheet.jsx +2 -0
  18. package/src/components/generic/ActionButtons.jsx +1 -1
  19. package/src/components/generic/GenericAuth.jsx +197 -21
  20. package/src/components/generic/GenericDetail.jsx +95 -3
  21. package/src/components/generic/GenericMain.jsx +7 -0
  22. package/src/components/generic/StandardModal.jsx +71 -158
  23. package/src/components/sms/SmsCampaigns.jsx +2487 -0
  24. package/src/components/sms/SmsInbox.jsx +18 -0
  25. package/src/components/sms/SmsThreadPanel.jsx +20 -0
  26. package/src/components/sms/smsEndpoints.js +23 -0
  27. package/src/components/styles/DataGrid.module.scss +22 -0
  28. package/src/components/styles/Form.module.scss +100 -2
  29. package/src/components/styles/Navigation.module.scss +354 -15
  30. package/src/components/styles/Notification.module.scss +140 -8
  31. package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
  32. package/src/components/styles/Profile.module.scss +157 -0
  33. package/src/components/styles/Sms.module.scss +451 -0
  34. package/src/components/styles/StandardModal.module.scss +273 -0
  35. package/src/components/styles/global.css +24 -5
  36. package/src/components/utils/navCollapsed.js +201 -0
  37. package/src/components/utils/rowActionSettings.js +76 -0
  38. package/src/components/utils/usePasskeysEnabled.js +84 -0
  39. package/src/components/utils/useShellLayout.js +131 -0
  40. package/src/index.js +103 -1
  41. package/src/utils/rememberTab.js +70 -0
@@ -266,7 +266,14 @@ const Login = ({
266
266
  // Stays pending: GenericAuth's curtain takes over from here,
267
267
  // and a button snapping back to idle underneath it reads as
268
268
  // "nothing happened".
269
- setSystemAuth(true);
269
+ //
270
+ // The second argument says HOW this sign-in happened.
271
+ // GenericAuth uses it to decide whether to offer a passkey
272
+ // afterwards — and offering one to somebody who just used one
273
+ // is the one thing that offer must never do. A host passing a
274
+ // plain `useState` setter here ignores the extra argument, so
275
+ // this is safe for every existing caller.
276
+ setSystemAuth(true, { method: 'passkey' });
270
277
  }
271
278
  } catch (error) {
272
279
  setIsPasskeyPending(false);
@@ -383,7 +390,7 @@ const Login = ({
383
390
  // profile check performs the one navigation
384
391
  // to '/'. Navigating here as well produced a
385
392
  // double route change and a visible flicker.
386
- setSystemAuth(true);
393
+ setSystemAuth(true, { method: 'password' });
387
394
  }
388
395
  }
389
396
  } else {
@@ -0,0 +1,269 @@
1
+ import React, { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { toast } from 'react-toastify';
3
+ import { KeyRound } from 'lucide-react';
4
+
5
+ import StandardModal from '../generic/StandardModal';
6
+ import { enrolPasskey, listPasskeys } from './passkeyClient';
7
+ import { isPasskeyCancellation, isPasskeySupported } from './webauthn';
8
+ import {
9
+ defaultPasskeyName,
10
+ neverAskPasskeyPrompt,
11
+ readPasskeyPromptRecord,
12
+ resolvePasskeyOptions,
13
+ shouldPromptForPasskey,
14
+ snoozePasskeyPrompt,
15
+ } from './passkeyPrompt';
16
+
17
+ import styles from '../styles/PasskeyEnrolPrompt.module.scss';
18
+
19
+ /**
20
+ * "Would you like to sign in with your fingerprint next time?", asked once.
21
+ *
22
+ * Rendered by GenericAuth inside the authenticated shell, so every app that
23
+ * turns passkeys on gets the offer without writing a screen for it — which is
24
+ * the whole point: a passkey is worth having and nobody goes looking for it in
25
+ * an account-settings tab.
26
+ *
27
+ * WHEN IT APPEARS is not decided here. Every gate lives in passkeyPrompt.js as
28
+ * one pure function, tested against each of its conditions, because the ways
29
+ * this goes wrong (asked on every refresh, asked of somebody who already has
30
+ * one, asked again after "Don't ask again") are all the same kind of mistake
31
+ * and none of them is visible in a render. This component does three things
32
+ * the pure function cannot: it asks the browser whether WebAuthn exists, it
33
+ * asks the server how many passkeys the user already holds, and it draws the
34
+ * dialog.
35
+ *
36
+ * The index request is the only network traffic this adds, it happens once per
37
+ * sign-in, and it only happens after every cheap gate has already passed — so
38
+ * an app with the prompt turned off, a browser that cannot do WebAuthn, and a
39
+ * page refresh all cost nothing.
40
+ */
41
+
42
+ /**
43
+ * The dialog itself, with no decisions in it.
44
+ *
45
+ * Separated from the component above so the markup can be rendered and
46
+ * asserted on its own (tests/passkeyPrompt.test.mjs) — the decision half needs
47
+ * a browser, a server and a storage, and none of those is what a markup test
48
+ * is about. Exported for the same reason a host might want it: an app with its
49
+ * own idea of when to ask can drive this directly.
50
+ */
51
+ export const PasskeyEnrolDialog = ({
52
+ isOpen = true,
53
+ copy,
54
+ isBusy = false,
55
+ onAdd,
56
+ onLater,
57
+ onNever,
58
+ }) => (
59
+ <StandardModal
60
+ isOpen={isOpen}
61
+ /* The X in the header and a click on the backdrop are the same
62
+ gesture as "Not now": a dismissal, not a refusal. Both snooze. */
63
+ onClose={onLater}
64
+ title={copy.title}
65
+ size="small"
66
+ variant="modal"
67
+ showHeader
68
+ >
69
+ <div className={styles.body}>
70
+ <span className={styles.mark} aria-hidden="true">
71
+ <KeyRound size={22} strokeWidth={1.5} />
72
+ </span>
73
+ <p className={styles.text}>{copy.body}</p>
74
+ <div className={styles.actions}>
75
+ <button
76
+ type="button"
77
+ className={styles.primary}
78
+ onClick={onAdd}
79
+ disabled={isBusy}
80
+ aria-busy={isBusy}
81
+ >
82
+ {isBusy ? copy.adding : copy.add}
83
+ </button>
84
+ <button
85
+ type="button"
86
+ className={styles.later}
87
+ onClick={onLater}
88
+ disabled={isBusy}
89
+ >
90
+ {copy.later}
91
+ </button>
92
+ <button
93
+ type="button"
94
+ className={styles.never}
95
+ onClick={onNever}
96
+ disabled={isBusy}
97
+ >
98
+ {copy.never}
99
+ </button>
100
+ </div>
101
+ </div>
102
+ </StandardModal>
103
+ );
104
+
105
+ const PasskeyEnrolPrompt = ({
106
+ /**
107
+ * The `passkeys` prop exactly as GenericAuth received it: `true`, `false`,
108
+ * or the object form `{enabled, promptOnLogin, snoozeDays, copy}`. See
109
+ * `resolvePasskeyOptions`.
110
+ */
111
+ passkeys,
112
+ /** The signed-in user. Only `id` is read, for the dismissal record. */
113
+ userProfile,
114
+ /** Merged over DEFAULT_AUTH_ENDPOINTS, as everywhere else in auth/. */
115
+ endpoints,
116
+ /**
117
+ * Whether a sign-in happened during THIS page's life, as opposed to a
118
+ * refresh that re-validated a session that already existed. GenericAuth
119
+ * knows, because the auth screens call the setter it owns; nothing this
120
+ * far down the tree could work it out for itself.
121
+ */
122
+ justSignedIn = false,
123
+ /** 'password' | 'passkey' | whatever else a host's screen reports. */
124
+ signInMethod,
125
+ /** Called after a credential is stored, in case the host wants to know. */
126
+ onEnrolled,
127
+ }) => {
128
+ const options = resolvePasskeyOptions(passkeys);
129
+ const copy = options.copy;
130
+ const userId = userProfile?.id;
131
+
132
+ const [isOpen, setIsOpen] = useState(false);
133
+ const [isBusy, setIsBusy] = useState(false);
134
+ // One decision per mounted prompt. Without this the effect would run again
135
+ // on any re-render that changed its inputs' identity, and a user who had
136
+ // just pressed "Not now" would be asked a second time in the same session.
137
+ const decidedRef = useRef(false);
138
+
139
+ useEffect(() => {
140
+ if (decidedRef.current) {
141
+ return undefined;
142
+ }
143
+
144
+ // The cheap gates first, and the request only if they all pass. This
145
+ // ordering IS the feature: `shouldPromptForPasskey` is called twice
146
+ // with the same values, once without a credential count and once with,
147
+ // so nothing can ask the server on a page refresh or on a browser that
148
+ // has no WebAuthn to begin with.
149
+ const worthAsking = shouldPromptForPasskey({
150
+ options,
151
+ supported: isPasskeySupported(),
152
+ justSignedIn,
153
+ signInMethod,
154
+ userId,
155
+ // Not known yet; a zero stands in for "the only thing still to
156
+ // check is how many they have".
157
+ credentialCount: 0,
158
+ record: readPasskeyPromptRecord(userId),
159
+ });
160
+
161
+ if (!worthAsking) {
162
+ return undefined;
163
+ }
164
+
165
+ decidedRef.current = true;
166
+
167
+ let cancelled = false;
168
+
169
+ listPasskeys(endpoints)
170
+ .then(({ count }) => {
171
+ if (cancelled) {
172
+ return;
173
+ }
174
+
175
+ setIsOpen(
176
+ shouldPromptForPasskey({
177
+ options,
178
+ supported: true,
179
+ justSignedIn,
180
+ signInMethod,
181
+ userId,
182
+ credentialCount: count,
183
+ record: readPasskeyPromptRecord(userId),
184
+ })
185
+ );
186
+ })
187
+ .catch(() => {
188
+ // A 404 (the feature is off on this deployment after all), a
189
+ // 401, a network failure. Silent: this is an unasked-for offer,
190
+ // and an error toast for one is noise about something the user
191
+ // never requested.
192
+ });
193
+
194
+ return () => {
195
+ cancelled = true;
196
+ };
197
+ // Once per mount. `options` is rebuilt every render and would restart
198
+ // this on each one; the values inside it are read above, at the moment
199
+ // the decision is made.
200
+ // eslint-disable-next-line react-hooks/exhaustive-deps
201
+ }, [userId, justSignedIn, signInMethod]);
202
+
203
+ const close = useCallback(() => {
204
+ setIsOpen(false);
205
+ setIsBusy(false);
206
+ }, []);
207
+
208
+ /** "Not now" — and the X, and the backdrop. */
209
+ const handleLater = useCallback(() => {
210
+ snoozePasskeyPrompt(userId);
211
+ close();
212
+ }, [userId, close]);
213
+
214
+ const handleNever = useCallback(() => {
215
+ neverAskPasskeyPrompt(userId);
216
+ close();
217
+ }, [userId, close]);
218
+
219
+ const handleAdd = useCallback(async () => {
220
+ setIsBusy(true);
221
+
222
+ try {
223
+ const result = await enrolPasskey(endpoints, {
224
+ name: defaultPasskeyName(),
225
+ });
226
+
227
+ toast.success(copy.success);
228
+ close();
229
+
230
+ if (typeof onEnrolled === 'function') {
231
+ onEnrolled(result);
232
+ }
233
+ } catch (error) {
234
+ setIsBusy(false);
235
+
236
+ // The person closed the browser's own sheet. They have answered
237
+ // the question; asking it again on the next sign-in would be
238
+ // asking them to answer it twice, so this snoozes exactly as "Not
239
+ // now" does — and says nothing, because nothing went wrong.
240
+ if (isPasskeyCancellation(error)) {
241
+ snoozePasskeyPrompt(userId);
242
+ close();
243
+ return;
244
+ }
245
+
246
+ // Anything else — the server refused the attestation, the request
247
+ // failed — is reported, and the dialog stays up so the button can
248
+ // be pressed again.
249
+ toast.error(error?.message || copy.failure);
250
+ }
251
+ }, [endpoints, copy, close, onEnrolled, userId]);
252
+
253
+ if (!isOpen) {
254
+ return null;
255
+ }
256
+
257
+ return (
258
+ <PasskeyEnrolDialog
259
+ isOpen
260
+ copy={copy}
261
+ isBusy={isBusy}
262
+ onAdd={handleAdd}
263
+ onLater={handleLater}
264
+ onNever={handleNever}
265
+ />
266
+ );
267
+ };
268
+
269
+ export default PasskeyEnrolPrompt;