@visns-studio/visns-components 6.32.3 → 6.34.3
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 +483 -0
- package/package.json +1 -1
- package/src/components/DataGrid.jsx +44 -2
- package/src/components/Field.jsx +32 -0
- package/src/components/Form.jsx +39 -2
- package/src/components/Navigation.jsx +274 -15
- package/src/components/Notification.jsx +13 -2
- package/src/components/TableFilter.jsx +14 -2
- package/src/components/auth/Login.jsx +11 -4
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- package/src/components/auth/Reset.jsx +1 -1
- package/src/components/auth/TwoFactorAuth.jsx +1 -1
- package/src/components/auth/Verify.jsx +2 -2
- package/src/components/auth/authEndpoints.js +43 -0
- package/src/components/auth/passkeyClient.js +102 -0
- package/src/components/auth/passkeyPrompt.js +408 -0
- package/src/components/auth/profileLayout.js +45 -0
- package/src/components/controls/DataGridSearch.jsx +16 -2
- package/src/components/controls/DataGridSortSheet.jsx +2 -0
- package/src/components/emailCampaigns/BlockEditor.jsx +486 -0
- package/src/components/emailCampaigns/CampaignEditor.jsx +614 -0
- package/src/components/emailCampaigns/CampaignReport.jsx +98 -0
- package/src/components/emailCampaigns/EmailCampaigns.jsx +422 -0
- package/src/components/emailCampaigns/EmailLists.jsx +505 -0
- package/src/components/emailCampaigns/emailCampaignApi.js +135 -0
- package/src/components/generic/ActionButtons.jsx +1 -1
- package/src/components/generic/GenericAuth.jsx +197 -21
- package/src/components/generic/GenericDetail.jsx +95 -3
- package/src/components/generic/GenericMain.jsx +7 -0
- package/src/components/generic/StandardModal.jsx +71 -158
- package/src/components/sms/SmsCampaigns.jsx +2517 -0
- package/src/components/sms/SmsInbox.jsx +20 -5
- package/src/components/sms/SmsLineSettings.jsx +2 -5
- package/src/components/sms/SmsThreadPanel.jsx +20 -0
- package/src/components/sms/smsEndpoints.js +23 -0
- package/src/components/styles/DataGrid.module.scss +22 -0
- package/src/components/styles/EmailCampaigns.module.scss +922 -0
- package/src/components/styles/Form.module.scss +100 -2
- package/src/components/styles/Navigation.module.scss +322 -5
- package/src/components/styles/Notification.module.scss +140 -8
- package/src/components/styles/PasskeyEnrolPrompt.module.scss +105 -0
- package/src/components/styles/Profile.module.scss +157 -0
- package/src/components/styles/Sms.module.scss +484 -4
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/Vault.module.scss +5 -3
- package/src/components/styles/global.css +38 -7
- package/src/components/utils/navCollapsed.js +201 -0
- package/src/components/utils/rowActionSettings.js +76 -0
- package/src/components/utils/usePasskeysEnabled.js +84 -0
- package/src/components/utils/useShellLayout.js +131 -0
- package/src/index.js +107 -1
- package/src/utils/rememberTab.js +70 -0
|
@@ -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;
|