@visns-studio/visns-components 6.32.3 → 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.
- package/README.md +470 -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 +9 -2
- package/src/components/auth/PasskeyEnrolPrompt.jsx +269 -0
- package/src/components/auth/Profile.jsx +422 -26
- 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/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 +2487 -0
- package/src/components/sms/SmsInbox.jsx +18 -0
- 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/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 +451 -0
- package/src/components/styles/StandardModal.module.scss +273 -0
- package/src/components/styles/global.css +24 -5
- 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 +103 -1
- package/src/utils/rememberTab.js +70 -0
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The three authenticated passkey requests, written once.
|
|
3
|
+
*
|
|
4
|
+
* Two screens do the same three things — the enrolment prompt that appears
|
|
5
|
+
* after sign-in and the passkeys tab on the profile — and the enrolment
|
|
6
|
+
* ceremony in particular is three ordered steps (challenge, browser, store)
|
|
7
|
+
* where getting the order or the field names wrong fails at the authenticator
|
|
8
|
+
* rather than at a lint. Two inline copies of that would drift the first time
|
|
9
|
+
* one of them was fixed, so there is one, and both screens call it.
|
|
10
|
+
*
|
|
11
|
+
* Errors arrive as a plain `Error` carrying the server's message, with
|
|
12
|
+
* CustomFetch's automatic red toast suppressed: the caller decides what a
|
|
13
|
+
* failure looks like, and a cancelled ceremony (see `isPasskeyCancellation`)
|
|
14
|
+
* is not a failure at all.
|
|
15
|
+
*/
|
|
16
|
+
import CustomFetch from '../Fetch';
|
|
17
|
+
import { resolveAuthEndpoints, resolvePasskeyDestroyUrl } from './authEndpoints';
|
|
18
|
+
import { createPasskeyCredential } from './webauthn';
|
|
19
|
+
import { countPasskeys, defaultPasskeyName } from './passkeyPrompt';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* CustomFetch as a plain promise, with its default error handling off.
|
|
23
|
+
*
|
|
24
|
+
* The same helper Login.jsx keeps for the sign-in ceremony, for the same
|
|
25
|
+
* reason — see the note there.
|
|
26
|
+
*/
|
|
27
|
+
const request = (url, method, body) =>
|
|
28
|
+
new Promise((resolve, reject) => {
|
|
29
|
+
CustomFetch(url, method, body, resolve, (message) =>
|
|
30
|
+
reject(new Error(message || 'Request failed.'))
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The passkeys this signed-in user holds.
|
|
36
|
+
*
|
|
37
|
+
* @param {object} [endpoints] merged over DEFAULT_AUTH_ENDPOINTS.
|
|
38
|
+
* @returns {Promise<{passkeys: Array, count: number|null, payload: *}>}
|
|
39
|
+
*/
|
|
40
|
+
export const listPasskeys = async (endpoints) => {
|
|
41
|
+
const urls = resolveAuthEndpoints(endpoints);
|
|
42
|
+
const payload = await request(urls.passkeyIndex, 'GET');
|
|
43
|
+
const count = countPasskeys(payload);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
passkeys: Array.isArray(payload?.passkeys)
|
|
47
|
+
? payload.passkeys
|
|
48
|
+
: Array.isArray(payload?.data)
|
|
49
|
+
? payload.data
|
|
50
|
+
: Array.isArray(payload)
|
|
51
|
+
? payload
|
|
52
|
+
: [],
|
|
53
|
+
count,
|
|
54
|
+
payload,
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Enrol a passkey: challenge, ceremony, store.
|
|
60
|
+
*
|
|
61
|
+
* The browser's own sheet goes up in the middle step, and everything it can
|
|
62
|
+
* report — a dismissal, a timeout, an authenticator that refuses — comes back
|
|
63
|
+
* as a thrown DOMException the caller passes to `isPasskeyCancellation`.
|
|
64
|
+
*
|
|
65
|
+
* `name` becomes the credential's `alias`, which is the label the management
|
|
66
|
+
* list shows. Blank is allowed by the server; this sends the device name
|
|
67
|
+
* instead, because "This device" beside a date reads better than a date alone.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} [endpoints]
|
|
70
|
+
* @param {{name?: string}} [options]
|
|
71
|
+
* @returns {Promise<*>} the server's response to the store step.
|
|
72
|
+
*/
|
|
73
|
+
export const enrolPasskey = async (endpoints, { name } = {}) => {
|
|
74
|
+
const urls = resolveAuthEndpoints(endpoints);
|
|
75
|
+
|
|
76
|
+
const options = await request(urls.passkeyRegisterOptions, 'POST', {});
|
|
77
|
+
const credential = await createPasskeyCredential(options);
|
|
78
|
+
|
|
79
|
+
const alias =
|
|
80
|
+
typeof name === 'string' && name.trim() !== ''
|
|
81
|
+
? name.trim().slice(0, 60)
|
|
82
|
+
: defaultPasskeyName();
|
|
83
|
+
|
|
84
|
+
return request(urls.passkeyRegister, 'POST', {
|
|
85
|
+
...credential,
|
|
86
|
+
alias,
|
|
87
|
+
});
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Remove one credential.
|
|
92
|
+
*
|
|
93
|
+
* @param {object} [endpoints]
|
|
94
|
+
* @param {string} id the credential id exactly as the server gave it.
|
|
95
|
+
*/
|
|
96
|
+
export const deletePasskey = (endpoints, id) => {
|
|
97
|
+
const urls = resolveAuthEndpoints(endpoints);
|
|
98
|
+
|
|
99
|
+
return request(resolvePasskeyDestroyUrl(urls.passkeyDestroy, id), 'DELETE');
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export default enrolPasskey;
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WHETHER TO ASK THIS PERSON, ON THIS DEVICE, RIGHT NOW, TO ADD A PASSKEY.
|
|
3
|
+
*
|
|
4
|
+
* The prompt itself is four lines of dialog (see PasskeyEnrolPrompt.jsx). The
|
|
5
|
+
* part worth writing down — and the part that goes wrong — is the decision in
|
|
6
|
+
* front of it, because every way of getting it wrong is a nag:
|
|
7
|
+
*
|
|
8
|
+
* - asked on a browser that cannot do WebAuthn: an offer the person cannot
|
|
9
|
+
* accept, and no way to make it go away;
|
|
10
|
+
* - asked on every page refresh instead of once per sign-in: the same dialog
|
|
11
|
+
* four times in a working day;
|
|
12
|
+
* - asked of somebody who ALREADY has a passkey, or who has just signed in
|
|
13
|
+
* WITH one: the feature advertising itself to the people using it;
|
|
14
|
+
* - asked again after "Don't ask again", because the record was written to a
|
|
15
|
+
* storage that threw and nobody caught it.
|
|
16
|
+
*
|
|
17
|
+
* So the decision lives here, as one pure function over plain values, with the
|
|
18
|
+
* storage access beside it — and both are tested (tests/passkeyPrompt.test.mjs)
|
|
19
|
+
* rather than verified by signing in and out of an app.
|
|
20
|
+
*
|
|
21
|
+
* NOTHING IN THIS FILE IMPORTS ANYTHING. No React, no fetch, no stylesheet: it
|
|
22
|
+
* runs under `node --test` as-is, the same way authEndpoints.js and
|
|
23
|
+
* webauthn.js do.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* localStorage key prefix for the per-user dismissal record.
|
|
28
|
+
*
|
|
29
|
+
* Per USER, not per app: two accounts sharing a browser are two different
|
|
30
|
+
* answers to "do you want a passkey on this device", and the device is what
|
|
31
|
+
* localStorage already scopes to. The record is `{dismissedAt, never}`.
|
|
32
|
+
*/
|
|
33
|
+
export const PASSKEY_PROMPT_STORAGE_PREFIX = 'visns.passkeys.prompt.';
|
|
34
|
+
|
|
35
|
+
/** How long "Not now" holds, in days, when the host names no other number. */
|
|
36
|
+
export const DEFAULT_PASSKEY_SNOOZE_DAYS = 30;
|
|
37
|
+
|
|
38
|
+
/** Milliseconds in a day, so the snooze arithmetic is readable. */
|
|
39
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The dialog's words, all overridable through the `copy` key of the `passkeys`
|
|
43
|
+
* prop — the same arrangement Login and TwoFactorAuth use for theirs.
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_PASSKEY_PROMPT_COPY = {
|
|
46
|
+
title: 'Sign in faster next time',
|
|
47
|
+
body: 'Add a passkey and this device signs you in with your fingerprint, face or screen PIN — no password to type and nothing to remember. The passkey never leaves this device.',
|
|
48
|
+
add: 'Add passkey',
|
|
49
|
+
adding: 'Waiting for your device…',
|
|
50
|
+
later: 'Not now',
|
|
51
|
+
never: "Don't ask again",
|
|
52
|
+
success: 'Passkey added. You can use it to sign in from now on.',
|
|
53
|
+
failure: 'That passkey could not be added. Please try again.',
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The `passkeys` prop, in one shape whatever the host passed.
|
|
58
|
+
*
|
|
59
|
+
* Three spellings, because the prop grew and the two older ones are in the
|
|
60
|
+
* wild:
|
|
61
|
+
*
|
|
62
|
+
* undefined / false the feature is off. Nothing renders — no button on the
|
|
63
|
+
* login screen, no tab on the profile, no prompt. An app
|
|
64
|
+
* that never passed the prop is unchanged.
|
|
65
|
+
* true what it has always meant (the login button), PLUS the
|
|
66
|
+
* enrolment prompt and the management tab.
|
|
67
|
+
* {…} the same as `true`, with the details named. Passing an
|
|
68
|
+
* object is itself the opt-in, so `enabled` only has to
|
|
69
|
+
* be written when it is `false` — which is how a host
|
|
70
|
+
* keeps one config object and switches the feature with
|
|
71
|
+
* one flag inside it.
|
|
72
|
+
*
|
|
73
|
+
* @param {boolean|object} [passkeys]
|
|
74
|
+
* @returns {{enabled: boolean, promptOnLogin: boolean, snoozeDays: number, copy: object}}
|
|
75
|
+
*/
|
|
76
|
+
export const resolvePasskeyOptions = (passkeys) => {
|
|
77
|
+
const off = {
|
|
78
|
+
enabled: false,
|
|
79
|
+
promptOnLogin: false,
|
|
80
|
+
snoozeDays: DEFAULT_PASSKEY_SNOOZE_DAYS,
|
|
81
|
+
copy: DEFAULT_PASSKEY_PROMPT_COPY,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
if (!passkeys) {
|
|
85
|
+
return off;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (passkeys === true || typeof passkeys !== 'object') {
|
|
89
|
+
return {
|
|
90
|
+
enabled: true,
|
|
91
|
+
promptOnLogin: true,
|
|
92
|
+
snoozeDays: DEFAULT_PASSKEY_SNOOZE_DAYS,
|
|
93
|
+
copy: DEFAULT_PASSKEY_PROMPT_COPY,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const enabled = passkeys.enabled !== false;
|
|
98
|
+
// Only the prompt is opt-OUT-able on its own: an app can offer passkeys on
|
|
99
|
+
// the login screen and in the profile without interrupting anybody.
|
|
100
|
+
const promptOnLogin = enabled && passkeys.promptOnLogin !== false;
|
|
101
|
+
|
|
102
|
+
const snoozeDays =
|
|
103
|
+
typeof passkeys.snoozeDays === 'number' &&
|
|
104
|
+
Number.isFinite(passkeys.snoozeDays) &&
|
|
105
|
+
passkeys.snoozeDays >= 0
|
|
106
|
+
? passkeys.snoozeDays
|
|
107
|
+
: DEFAULT_PASSKEY_SNOOZE_DAYS;
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
enabled,
|
|
111
|
+
promptOnLogin,
|
|
112
|
+
snoozeDays,
|
|
113
|
+
copy: { ...DEFAULT_PASSKEY_PROMPT_COPY, ...(passkeys.copy || {}) },
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** The localStorage key holding one user's answer. */
|
|
118
|
+
export const passkeyPromptStorageKey = (userId) =>
|
|
119
|
+
`${PASSKEY_PROMPT_STORAGE_PREFIX}${userId}`;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The storage to use: whatever was handed in, else `window.localStorage`.
|
|
123
|
+
*
|
|
124
|
+
* MERELY REACHING FOR `window.localStorage` CAN THROW — Safari with cookies
|
|
125
|
+
* blocked, a sandboxed iframe, a browser in a mode that disables site data —
|
|
126
|
+
* so even this is inside the try/catch, not only the get and set below.
|
|
127
|
+
*
|
|
128
|
+
* @param {Storage} [storage] An explicit store, which is what the tests pass.
|
|
129
|
+
* @returns {Storage|null}
|
|
130
|
+
*/
|
|
131
|
+
const resolveStorage = (storage) => {
|
|
132
|
+
if (storage) {
|
|
133
|
+
return storage;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
try {
|
|
137
|
+
if (typeof window === 'undefined') {
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return window.localStorage || null;
|
|
142
|
+
} catch (error) {
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* This user's dismissal record, or null.
|
|
149
|
+
*
|
|
150
|
+
* Null means "nothing is on file" AND "the file could not be read" — which are
|
|
151
|
+
* the same thing to the caller: with no record the prompt is allowed, and a
|
|
152
|
+
* browser that cannot remember a dismissal cannot be nagged by one either,
|
|
153
|
+
* because the prompt is shown at most once per sign-in regardless.
|
|
154
|
+
*
|
|
155
|
+
* @param {string|number} userId
|
|
156
|
+
* @param {Storage} [storage]
|
|
157
|
+
* @returns {{dismissedAt: number, never: boolean}|null}
|
|
158
|
+
*/
|
|
159
|
+
export const readPasskeyPromptRecord = (userId, storage) => {
|
|
160
|
+
const store = resolveStorage(storage);
|
|
161
|
+
|
|
162
|
+
if (!store || userId === null || userId === undefined || userId === '') {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
const raw = store.getItem(passkeyPromptStorageKey(userId));
|
|
168
|
+
|
|
169
|
+
if (!raw) {
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const parsed = JSON.parse(raw);
|
|
174
|
+
|
|
175
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return {
|
|
180
|
+
dismissedAt:
|
|
181
|
+
typeof parsed.dismissedAt === 'number' ? parsed.dismissedAt : 0,
|
|
182
|
+
never: parsed.never === true,
|
|
183
|
+
};
|
|
184
|
+
} catch (error) {
|
|
185
|
+
// Unparseable, unreadable, or a key some other version of this library
|
|
186
|
+
// wrote in another shape. Treated as absent rather than repaired.
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Write this user's dismissal record. Silent on failure, by design: a storage
|
|
193
|
+
* that refuses the write must not take down the dialog that asked for it.
|
|
194
|
+
*
|
|
195
|
+
* @returns {boolean} whether it was stored.
|
|
196
|
+
*/
|
|
197
|
+
export const writePasskeyPromptRecord = (userId, record, storage) => {
|
|
198
|
+
const store = resolveStorage(storage);
|
|
199
|
+
|
|
200
|
+
if (!store || userId === null || userId === undefined || userId === '') {
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
try {
|
|
205
|
+
store.setItem(
|
|
206
|
+
passkeyPromptStorageKey(userId),
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
dismissedAt:
|
|
209
|
+
typeof record?.dismissedAt === 'number'
|
|
210
|
+
? record.dismissedAt
|
|
211
|
+
: Date.now(),
|
|
212
|
+
never: record?.never === true,
|
|
213
|
+
})
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
return true;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
return false;
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/** "Not now" — hold the prompt back for `snoozeDays`. */
|
|
223
|
+
export const snoozePasskeyPrompt = (userId, { now = Date.now(), storage } = {}) =>
|
|
224
|
+
writePasskeyPromptRecord(userId, { dismissedAt: now, never: false }, storage);
|
|
225
|
+
|
|
226
|
+
/** "Don't ask again" — on this device, for this user, permanently. */
|
|
227
|
+
export const neverAskPasskeyPrompt = (userId, { now = Date.now(), storage } = {}) =>
|
|
228
|
+
writePasskeyPromptRecord(userId, { dismissedAt: now, never: true }, storage);
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Does a stored record still hold the prompt back?
|
|
232
|
+
*
|
|
233
|
+
* `never` always does. A snooze does until `snoozeDays` have passed, counted
|
|
234
|
+
* from when it was taken. `snoozeDays: 0` means the snooze lasts until the
|
|
235
|
+
* next sign-in and no longer, which is a legitimate thing for a host to want
|
|
236
|
+
* and falls out of the arithmetic rather than needing a case of its own.
|
|
237
|
+
*
|
|
238
|
+
* A record with a `dismissedAt` in the future — a clock that was wrong, or has
|
|
239
|
+
* since been corrected — still counts as a dismissal for its window; nothing
|
|
240
|
+
* here tries to be cleverer than the device's own clock.
|
|
241
|
+
*
|
|
242
|
+
* @param {{dismissedAt: number, never: boolean}|null} record
|
|
243
|
+
* @param {{now?: number, snoozeDays?: number}} [options]
|
|
244
|
+
* @returns {boolean}
|
|
245
|
+
*/
|
|
246
|
+
export const isPasskeyPromptBlocked = (
|
|
247
|
+
record,
|
|
248
|
+
{ now = Date.now(), snoozeDays = DEFAULT_PASSKEY_SNOOZE_DAYS } = {}
|
|
249
|
+
) => {
|
|
250
|
+
if (!record) {
|
|
251
|
+
return false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (record.never === true) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (typeof record.dismissedAt !== 'number' || record.dismissedAt <= 0) {
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return now < record.dismissedAt + snoozeDays * DAY_MS;
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* How many passkeys an `index` response says this user holds.
|
|
267
|
+
*
|
|
268
|
+
* The Laravel controller answers `{error, enabled, passkeys: [...]}`, but this
|
|
269
|
+
* reads the two other shapes a REST list arrives in as well — a bare array,
|
|
270
|
+
* and `{data: [...]}` — because the endpoint is overridable and the app on the
|
|
271
|
+
* other end of an overridden one is not this library's.
|
|
272
|
+
*
|
|
273
|
+
* ANYTHING UNRECOGNISABLE IS `null`, NOT `0`. "I could not tell" and "this
|
|
274
|
+
* person has none" lead to opposite decisions, and the gate below treats only
|
|
275
|
+
* a confident zero as a reason to prompt.
|
|
276
|
+
*
|
|
277
|
+
* @param {*} payload
|
|
278
|
+
* @returns {number|null}
|
|
279
|
+
*/
|
|
280
|
+
export const countPasskeys = (payload) => {
|
|
281
|
+
if (Array.isArray(payload)) {
|
|
282
|
+
return payload.length;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (!payload || typeof payload !== 'object') {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const list = [payload.passkeys, payload.credentials, payload.data].find(
|
|
290
|
+
(candidate) => Array.isArray(candidate)
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
return list ? list.length : null;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* THE DECISION. Every gate, in one place, over plain values.
|
|
298
|
+
*
|
|
299
|
+
* All of these must hold:
|
|
300
|
+
*
|
|
301
|
+
* 1. the host turned passkeys on, and left the prompt on;
|
|
302
|
+
* 2. the browser can do WebAuthn (`isPasskeySupported()`, passed in as
|
|
303
|
+
* `supported` so this stays pure);
|
|
304
|
+
* 3. the person signed in during THIS page's life — not refreshed a page
|
|
305
|
+
* that already had a session (see GenericAuth's `signInEvent`);
|
|
306
|
+
* 4. they did not sign in with a passkey, which would make the offer absurd;
|
|
307
|
+
* 5. we know who they are, because the dismissal is recorded against their
|
|
308
|
+
* id and a prompt nobody can dismiss is a prompt that returns forever;
|
|
309
|
+
* 6. they hold no passkeys yet, KNOWN to be zero — an index request that
|
|
310
|
+
* 404ed, failed or answered something unreadable means no prompt;
|
|
311
|
+
* 7. nothing on this device says they already said no.
|
|
312
|
+
*
|
|
313
|
+
* @param {object} input
|
|
314
|
+
* @param {object} input.options from `resolvePasskeyOptions`
|
|
315
|
+
* @param {boolean} input.supported `isPasskeySupported()`
|
|
316
|
+
* @param {boolean} input.justSignedIn a sign-in happened in this session
|
|
317
|
+
* @param {string} [input.signInMethod] 'password' | 'passkey' | anything
|
|
318
|
+
* @param {string|number} [input.userId]
|
|
319
|
+
* @param {number|null} [input.credentialCount] null when unknown
|
|
320
|
+
* @param {object|null} [input.record] from `readPasskeyPromptRecord`
|
|
321
|
+
* @param {number} [input.now]
|
|
322
|
+
* @returns {boolean}
|
|
323
|
+
*/
|
|
324
|
+
export const shouldPromptForPasskey = ({
|
|
325
|
+
options,
|
|
326
|
+
supported,
|
|
327
|
+
justSignedIn,
|
|
328
|
+
signInMethod,
|
|
329
|
+
userId,
|
|
330
|
+
credentialCount,
|
|
331
|
+
record,
|
|
332
|
+
now = Date.now(),
|
|
333
|
+
} = {}) => {
|
|
334
|
+
const resolved = options || resolvePasskeyOptions(false);
|
|
335
|
+
|
|
336
|
+
if (!resolved.enabled || !resolved.promptOnLogin) {
|
|
337
|
+
return false;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (!supported || justSignedIn !== true) {
|
|
341
|
+
return false;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (signInMethod === 'passkey') {
|
|
345
|
+
return false;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
if (userId === null || userId === undefined || userId === '') {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (typeof credentialCount !== 'number' || credentialCount > 0) {
|
|
353
|
+
return false;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
return !isPasskeyPromptBlocked(record, {
|
|
357
|
+
now,
|
|
358
|
+
snoozeDays: resolved.snoozeDays,
|
|
359
|
+
});
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* A name for the credential about to be created.
|
|
364
|
+
*
|
|
365
|
+
* It is what the person will see in a list six months from now, next to two
|
|
366
|
+
* others, and the honest answer is which device it is on. `userAgentData`
|
|
367
|
+
* gives a browser brand without parsing a user-agent string; where it is
|
|
368
|
+
* missing — Safari, Firefox — the generic phrase is better than a regex over
|
|
369
|
+
* `navigator.userAgent`, which is a well-known way to be confidently wrong.
|
|
370
|
+
*
|
|
371
|
+
* Capped at 60 characters, which is the Laravel side's `alias_max_length`
|
|
372
|
+
* default; a longer one would come back as a validation error.
|
|
373
|
+
*
|
|
374
|
+
* @param {object} [nav] `navigator`, injected so this can be tested.
|
|
375
|
+
* @returns {string}
|
|
376
|
+
*/
|
|
377
|
+
export const defaultPasskeyName = (nav) => {
|
|
378
|
+
const source =
|
|
379
|
+
nav || (typeof navigator !== 'undefined' ? navigator : undefined);
|
|
380
|
+
|
|
381
|
+
const brands = source?.userAgentData?.brands;
|
|
382
|
+
const brand = Array.isArray(brands)
|
|
383
|
+
? brands
|
|
384
|
+
.map((entry) => entry?.brand)
|
|
385
|
+
.find(
|
|
386
|
+
(name) =>
|
|
387
|
+
typeof name === 'string' &&
|
|
388
|
+
name !== '' &&
|
|
389
|
+
// The deliberately meaningless entry every Chromium
|
|
390
|
+
// browser includes to catch code that trusts the list.
|
|
391
|
+
!/not.*a.*brand/i.test(name)
|
|
392
|
+
)
|
|
393
|
+
: null;
|
|
394
|
+
|
|
395
|
+
const platform =
|
|
396
|
+
typeof source?.userAgentData?.platform === 'string' &&
|
|
397
|
+
source.userAgentData.platform !== ''
|
|
398
|
+
? source.userAgentData.platform
|
|
399
|
+
: '';
|
|
400
|
+
|
|
401
|
+
const name = brand
|
|
402
|
+
? `${brand}${platform ? ` on ${platform}` : ''}`
|
|
403
|
+
: 'This device';
|
|
404
|
+
|
|
405
|
+
return name.slice(0, 60);
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
export default shouldPromptForPasskey;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { isSidebarShell } from '../utils/useShellLayout';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Where the Profile screen's tabs go.
|
|
5
|
+
*
|
|
6
|
+
* Split out of the component for the same reason `shouldUnderlineTabs` was
|
|
7
|
+
* split out of GenericIndex: it is the one decision here that fails silently.
|
|
8
|
+
* Pick the strip on a page that is already wearing a rail and you get two
|
|
9
|
+
* rails; pick the rail on a top-bar app and every existing deployment's
|
|
10
|
+
* account screen quietly changes shape. Neither shows up in a render test,
|
|
11
|
+
* because Profile imports stylesheets and a signature pad and the test runner
|
|
12
|
+
* cannot mount it — but the rule can be pinned exactly, and is (see
|
|
13
|
+
* tests/shellLayout.test.mjs).
|
|
14
|
+
*
|
|
15
|
+
* THE DEFAULT IS THE POINT. A host does not have to pass anything:
|
|
16
|
+
*
|
|
17
|
+
* - `layout="full"` — the header-bar shell — keeps the rail it has always
|
|
18
|
+
* had. The page has no other navigation column, so the rail costs nothing
|
|
19
|
+
* that was being used for something else.
|
|
20
|
+
* - `layout="cms"` / `layout="simple"` — the sidebar shells — get the strip.
|
|
21
|
+
* On those the app's own navigation is ALREADY a vertical rail down the
|
|
22
|
+
* left edge, and a second vertical rail immediately beside it is two
|
|
23
|
+
* columns of navigation to reach two tabs.
|
|
24
|
+
*
|
|
25
|
+
* The shell layout comes from `<html data-layout>` (GenericAuth writes it; see
|
|
26
|
+
* utils/useShellLayout.js), so neither consuming app has to be edited for this
|
|
27
|
+
* to land the right way up.
|
|
28
|
+
*
|
|
29
|
+
* `tabLayout`, when it is one of the two names, wins over all of that — the
|
|
30
|
+
* usual escape hatch for the page that knows better than the rule.
|
|
31
|
+
*
|
|
32
|
+
* @param {object} options
|
|
33
|
+
* @param {string} [options.tabLayout] 'rail' | 'underline', or nothing.
|
|
34
|
+
* @param {string} [options.shellLayout] 'full' | 'cms' | 'simple'.
|
|
35
|
+
* @returns {'rail'|'underline'}
|
|
36
|
+
*/
|
|
37
|
+
export function resolveProfileTabLayout({ tabLayout, shellLayout } = {}) {
|
|
38
|
+
if (tabLayout === 'rail' || tabLayout === 'underline') {
|
|
39
|
+
return tabLayout;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return isSidebarShell(shellLayout) ? 'underline' : 'rail';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export default resolveProfileTabLayout;
|
|
@@ -174,7 +174,11 @@ const DataGridSearch = ({
|
|
|
174
174
|
return (
|
|
175
175
|
<div className={buttonClassName}>
|
|
176
176
|
{form.export && form.export.url && (
|
|
177
|
-
<button
|
|
177
|
+
<button
|
|
178
|
+
className={styles.btn}
|
|
179
|
+
title="Download what this grid is showing as a file"
|
|
180
|
+
onClick={exportTrigger}
|
|
181
|
+
>
|
|
178
182
|
<ExternalLink
|
|
179
183
|
strokeWidth={2}
|
|
180
184
|
size={16}
|
|
@@ -184,7 +188,11 @@ const DataGridSearch = ({
|
|
|
184
188
|
</button>
|
|
185
189
|
)}
|
|
186
190
|
{form.sort && form.sort.title && (
|
|
187
|
-
<button
|
|
191
|
+
<button
|
|
192
|
+
className={styles.btn}
|
|
193
|
+
title="Choose the order the rows are listed in"
|
|
194
|
+
onClick={sortTrigger}
|
|
195
|
+
>
|
|
188
196
|
<ArrowUpDown
|
|
189
197
|
strokeWidth={2}
|
|
190
198
|
size={16}
|
|
@@ -196,6 +204,7 @@ const DataGridSearch = ({
|
|
|
196
204
|
{form.bulkUpload && form.bulkUpload.enabled && (
|
|
197
205
|
<button
|
|
198
206
|
className={styles.btn}
|
|
207
|
+
title="Add many records at once from a spreadsheet"
|
|
199
208
|
onClick={(e) => {
|
|
200
209
|
e.preventDefault();
|
|
201
210
|
if (onBulkUploadClick) {
|
|
@@ -218,6 +227,10 @@ const DataGridSearch = ({
|
|
|
218
227
|
(form.url && form.create?.title !== '') ? (
|
|
219
228
|
<button
|
|
220
229
|
className={styles.btn}
|
|
230
|
+
title={
|
|
231
|
+
form.create?.tooltip ||
|
|
232
|
+
'Create a new record and add it to this list'
|
|
233
|
+
}
|
|
221
234
|
onClick={(e) => {
|
|
222
235
|
e.preventDefault();
|
|
223
236
|
|
|
@@ -259,6 +272,7 @@ const DataGridSearch = ({
|
|
|
259
272
|
) : form.url && form.modal === false ? (
|
|
260
273
|
<button
|
|
261
274
|
className={styles.btn}
|
|
275
|
+
title="Create a new record and add it to this list"
|
|
262
276
|
onClick={(e) => {
|
|
263
277
|
e.preventDefault();
|
|
264
278
|
|
|
@@ -118,6 +118,7 @@ const DataGridSortSheet = ({
|
|
|
118
118
|
<span className={styles.sortSheetDirections}>
|
|
119
119
|
<button
|
|
120
120
|
type="button"
|
|
121
|
+
title={`List the rows by ${option.label}, smallest or earliest first`}
|
|
121
122
|
aria-label={`Sort by ${option.label}, ascending`}
|
|
122
123
|
aria-pressed={active && sort === 'asc'}
|
|
123
124
|
className={
|
|
@@ -131,6 +132,7 @@ const DataGridSortSheet = ({
|
|
|
131
132
|
</button>
|
|
132
133
|
<button
|
|
133
134
|
type="button"
|
|
135
|
+
title={`List the rows by ${option.label}, largest or latest first`}
|
|
134
136
|
aria-label={`Sort by ${option.label}, descending`}
|
|
135
137
|
aria-pressed={active && sort === 'desc'}
|
|
136
138
|
className={
|
|
@@ -10,7 +10,7 @@ function ActionButtons({ actions, onActionClick }) {
|
|
|
10
10
|
key={`action-${action.id}`}
|
|
11
11
|
className={styles.actionButton}
|
|
12
12
|
onClick={() => onActionClick(action)}
|
|
13
|
-
title={action.label}
|
|
13
|
+
title={action.tooltip || action.label}
|
|
14
14
|
>
|
|
15
15
|
{action.label}
|
|
16
16
|
</button>
|