@visns-studio/visns-components 6.6.4 → 6.15.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 +1623 -18
- package/package.json +10 -4
- package/src/components/DataGrid.jsx +171 -37
- package/src/components/Fetch.jsx +80 -3
- package/src/components/Form.jsx +9 -0
- package/src/components/Navigation.jsx +109 -50
- package/src/components/Notification.jsx +279 -9
- package/src/components/TableFilter.jsx +14 -1
- package/src/components/auth/AuthBrandPanel.jsx +43 -0
- package/src/components/auth/AuthLoading.jsx +78 -0
- package/src/components/auth/AuthShell.jsx +59 -0
- package/src/components/auth/ClientAuth.jsx +161 -0
- package/src/components/auth/ClientAuthFrame.jsx +59 -0
- package/src/components/auth/ClientLogin.jsx +266 -55
- package/src/components/auth/ClientOTPVerify.jsx +587 -115
- package/src/components/auth/ImpersonateGate.jsx +254 -0
- package/src/components/auth/Login.jsx +134 -41
- package/src/components/auth/LogoutScreen.jsx +112 -0
- package/src/components/auth/Reset.jsx +134 -77
- package/src/components/auth/TwoFactorAuth.jsx +475 -297
- package/src/components/auth/Verify.jsx +237 -126
- package/src/components/auth/authEndpoints.js +105 -0
- package/src/components/auth/authFont.js +23 -0
- package/src/components/auth/authHelpers.js +465 -0
- package/src/components/auth/clientAuthProtocols.js +240 -0
- package/src/components/auth/useOptionalRouter.js +49 -0
- package/src/components/callQueue/CallQueuePop.jsx +1502 -0
- package/src/components/callQueue/CallQueueSettings.jsx +508 -0
- package/src/components/callQueue/callQueueHelpers.js +280 -0
- package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
- package/src/components/columns/ColumnRenderers.jsx +53 -7
- package/src/components/generic/GenericAuth.jsx +163 -96
- package/src/components/generic/GenericDetail.jsx +215 -90
- package/src/components/generic/GenericIndex.jsx +20 -47
- package/src/components/generic/GenericMain.jsx +5 -0
- package/src/components/generic/GroupedReportRenderer.jsx +1 -5
- package/src/components/generic/StandardModal.jsx +17 -5
- package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
- package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
- package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
- package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
- package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
- package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
- package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
- package/src/components/navActive.js +60 -0
- package/src/components/notify/desktopNotifications.js +256 -0
- package/src/components/sketch/SketchField.jsx +12 -2
- package/src/components/sms/SmsComposeModal.jsx +495 -0
- package/src/components/sms/SmsInbox.jsx +596 -0
- package/src/components/sms/SmsInboxBadge.jsx +468 -0
- package/src/components/sms/SmsLineSettings.jsx +854 -0
- package/src/components/sms/SmsThreadPanel.jsx +1184 -0
- package/src/components/sms/smsEndpoints.js +56 -0
- package/src/components/sms/smsHelpers.js +1075 -0
- package/src/components/sms/smsLiveState.js +132 -0
- package/src/components/sms/useSmsLive.js +307 -0
- package/src/components/styles/CallQueuePop.module.scss +646 -0
- package/src/components/styles/CallQueueSettings.module.scss +460 -0
- package/src/components/styles/ClientAuth.module.scss +229 -0
- package/src/components/styles/GenericDetail.module.scss +153 -26
- package/src/components/styles/GenericIndex.module.scss +13 -0
- package/src/components/styles/GenericMain.module.scss +19 -2
- package/src/components/styles/ImpersonateGate.module.scss +85 -0
- package/src/components/styles/Login.module.scss +13 -122
- package/src/components/styles/LogoutScreen.module.scss +89 -0
- package/src/components/styles/Navigation.module.scss +509 -223
- package/src/components/styles/Notification.module.scss +103 -0
- package/src/components/styles/Reset.module.scss +52 -186
- package/src/components/styles/Sms.module.scss +2382 -0
- package/src/components/styles/TableFilter.module.scss +118 -98
- package/src/components/styles/TwoFactorAuth.module.scss +115 -176
- package/src/components/styles/Vault.module.scss +1983 -0
- package/src/components/styles/Verify.module.scss +57 -180
- package/src/components/styles/_authBrandPanel.scss +163 -0
- package/src/components/styles/_authShell.scss +369 -0
- package/src/components/utils/buildEnv.js +67 -0
- package/src/components/utils/displayValue.js +94 -0
- package/src/components/utils/useDensity.js +345 -9
- package/src/components/vault/OtpChip.jsx +208 -0
- package/src/components/vault/PasswordGenerator.jsx +145 -0
- package/src/components/vault/QrScanner.jsx +326 -0
- package/src/components/vault/VaultAccessLog.jsx +225 -0
- package/src/components/vault/VaultConfirmPanel.jsx +113 -0
- package/src/components/vault/VaultEntryForm.jsx +740 -0
- package/src/components/vault/VaultManager.jsx +1000 -0
- package/src/components/vault/VaultQuickSearch.jsx +681 -0
- package/src/components/vault/qrDecode.js +257 -0
- package/src/components/vault/useDebouncedValue.js +22 -0
- package/src/components/vault/useVaultReveal.js +160 -0
- package/src/components/vault/vaultClipboard.js +33 -0
- package/src/components/vault/vaultEndpoints.js +40 -0
- package/src/components/vault/vaultFit.js +116 -0
- package/src/components/vault/vaultHelpers.js +547 -0
- package/src/components/vault/vaultNavigation.jsx +58 -0
- package/src/components/vault/vaultOtp.js +105 -0
- package/src/index.js +233 -1
|
@@ -0,0 +1,547 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for the vault.
|
|
3
|
+
*
|
|
4
|
+
* No React and no DOM beyond `globalThis.crypto`, so `node --test` can import
|
|
5
|
+
* this directly (tests/vault.test.mjs). Everything the vault components do
|
|
6
|
+
* that can be reasoned about without a browser lives here: parsing a 2FA
|
|
7
|
+
* secret, generating and scoring a password, formatting a code, and matching
|
|
8
|
+
* the keyboard shortcut.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/* -------------------------------------------------------------------------
|
|
12
|
+
* 2FA secrets
|
|
13
|
+
* ---------------------------------------------------------------------- */
|
|
14
|
+
|
|
15
|
+
/** RFC 4648 base32, which is the alphabet every authenticator app speaks. */
|
|
16
|
+
const BASE32_ALPHABET = /^[A-Z2-7]+$/;
|
|
17
|
+
|
|
18
|
+
/** What an `otpauth://` URI leaves out, and what a bare secret always means. */
|
|
19
|
+
export const OTP_DEFAULTS = Object.freeze({
|
|
20
|
+
digits: 6,
|
|
21
|
+
period: 30,
|
|
22
|
+
algorithm: 'SHA1',
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const OTP_ALGORITHMS = ['SHA1', 'SHA256', 'SHA512'];
|
|
26
|
+
|
|
27
|
+
/** The one message a user ever sees for an unreadable secret. */
|
|
28
|
+
export const OTP_PARSE_ERROR =
|
|
29
|
+
"That doesn't look like a 2FA secret or otpauth link.";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Strip the decoration authenticator apps and password managers add when they
|
|
33
|
+
* print a secret: spaces, hyphens, underscores, and base32's `=` padding.
|
|
34
|
+
* Case is not meaningful in base32, so lowercase input is simply uppercased.
|
|
35
|
+
*/
|
|
36
|
+
export const cleanBase32 = (value) =>
|
|
37
|
+
String(value ?? '')
|
|
38
|
+
.replace(/[\s\-_]/g, '')
|
|
39
|
+
.replace(/=+$/, '')
|
|
40
|
+
.toUpperCase();
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Is this a base32 secret, once the decoration is stripped?
|
|
44
|
+
*
|
|
45
|
+
* Format only — length is the backend's business, and inventing a minimum
|
|
46
|
+
* here would reject secrets that work perfectly well in an authenticator app.
|
|
47
|
+
*/
|
|
48
|
+
export const isValidBase32 = (value) => {
|
|
49
|
+
const cleaned = cleanBase32(value);
|
|
50
|
+
|
|
51
|
+
return cleaned.length > 0 && BASE32_ALPHABET.test(cleaned);
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/** A query value that is a positive integer inside `[min, max]`, or null. */
|
|
55
|
+
const boundedInt = (raw, min, max) => {
|
|
56
|
+
if (raw === null || raw === undefined || raw === '') return null;
|
|
57
|
+
|
|
58
|
+
const parsed = Number(raw);
|
|
59
|
+
|
|
60
|
+
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return null;
|
|
61
|
+
|
|
62
|
+
return parsed;
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Read a 2FA secret out of whatever the user pasted.
|
|
67
|
+
*
|
|
68
|
+
* Accepts either a full `otpauth://totp/...` URI — the thing a QR code
|
|
69
|
+
* actually encodes, and what every "copy setup link" button hands over — or a
|
|
70
|
+
* bare base32 secret. Both come back in the same shape, so the caller never
|
|
71
|
+
* has to care which it was handed:
|
|
72
|
+
*
|
|
73
|
+
* { secret, digits, period, algorithm, issuer, account }
|
|
74
|
+
*
|
|
75
|
+
* Parameters absent from the URI take the values in `OTP_DEFAULTS`, which are
|
|
76
|
+
* also what a bare secret means. Anything unreadable throws `OTP_PARSE_ERROR`
|
|
77
|
+
* — a sentence, because it is shown to the user verbatim.
|
|
78
|
+
*
|
|
79
|
+
* Note the URI is parsed by hand rather than through `new URL()`: `otpauth:`
|
|
80
|
+
* is a non-special scheme, so the label lands in `host` or `pathname`
|
|
81
|
+
* depending on the engine, and hosts get lowercased. The issuer is part of
|
|
82
|
+
* that label and its case matters.
|
|
83
|
+
*/
|
|
84
|
+
export const parseOtpAuthUri = (input) => {
|
|
85
|
+
const raw = String(input ?? '').trim();
|
|
86
|
+
|
|
87
|
+
if (raw === '') throw new Error(OTP_PARSE_ERROR);
|
|
88
|
+
|
|
89
|
+
if (!/^otpauth:\/\//i.test(raw)) {
|
|
90
|
+
// Not a link, so it has to be a bare secret.
|
|
91
|
+
if (!isValidBase32(raw)) throw new Error(OTP_PARSE_ERROR);
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
secret: cleanBase32(raw),
|
|
95
|
+
...OTP_DEFAULTS,
|
|
96
|
+
issuer: '',
|
|
97
|
+
account: '',
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const withoutScheme = raw.slice(raw.indexOf('//') + 2);
|
|
102
|
+
const queryAt = withoutScheme.indexOf('?');
|
|
103
|
+
const path = queryAt === -1 ? withoutScheme : withoutScheme.slice(0, queryAt);
|
|
104
|
+
const query = queryAt === -1 ? '' : withoutScheme.slice(queryAt + 1);
|
|
105
|
+
|
|
106
|
+
const [type, ...labelParts] = path.split('/');
|
|
107
|
+
|
|
108
|
+
// `hotp` is counter-based: there is no clock to read, so a code cannot be
|
|
109
|
+
// shown on demand. Saying so beats a generic parse failure.
|
|
110
|
+
if (String(type).toLowerCase() !== 'totp') {
|
|
111
|
+
throw new Error('Only time-based (TOTP) 2FA links are supported.');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const params = new URLSearchParams(query);
|
|
115
|
+
const secretParam = params.get('secret');
|
|
116
|
+
|
|
117
|
+
if (!secretParam || !isValidBase32(secretParam)) {
|
|
118
|
+
throw new Error(OTP_PARSE_ERROR);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// The label is `Issuer:account`, percent-encoded — `ACME%20Co:john%40x.com`.
|
|
122
|
+
let label = '';
|
|
123
|
+
try {
|
|
124
|
+
label = decodeURIComponent(labelParts.join('/'));
|
|
125
|
+
} catch {
|
|
126
|
+
label = labelParts.join('/');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const separator = label.indexOf(':');
|
|
130
|
+
let issuer = separator === -1 ? '' : label.slice(0, separator).trim();
|
|
131
|
+
const account =
|
|
132
|
+
separator === -1 ? label.trim() : label.slice(separator + 1).trim();
|
|
133
|
+
|
|
134
|
+
// The explicit `issuer` parameter is authoritative when both are present.
|
|
135
|
+
const issuerParam = params.get('issuer');
|
|
136
|
+
if (issuerParam) issuer = issuerParam.trim();
|
|
137
|
+
|
|
138
|
+
const algorithmParam = String(params.get('algorithm') || '').toUpperCase();
|
|
139
|
+
|
|
140
|
+
return {
|
|
141
|
+
secret: cleanBase32(secretParam),
|
|
142
|
+
// An out-of-range or non-numeric parameter falls back to the default
|
|
143
|
+
// rather than throwing: the secret is the part that must be right.
|
|
144
|
+
digits: boundedInt(params.get('digits'), 6, 10) ?? OTP_DEFAULTS.digits,
|
|
145
|
+
period: boundedInt(params.get('period'), 1, 300) ?? OTP_DEFAULTS.period,
|
|
146
|
+
algorithm: OTP_ALGORITHMS.includes(algorithmParam)
|
|
147
|
+
? algorithmParam
|
|
148
|
+
: OTP_DEFAULTS.algorithm,
|
|
149
|
+
issuer,
|
|
150
|
+
account,
|
|
151
|
+
};
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
/** `6 digits · 30 s · SHA1` — the live feedback line under the secret field. */
|
|
155
|
+
export const describeOtpConfig = (parsed) => {
|
|
156
|
+
if (!parsed) return '';
|
|
157
|
+
|
|
158
|
+
return `${parsed.digits} digits · ${parsed.period} s · ${parsed.algorithm}`;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
/* -------------------------------------------------------------------------
|
|
162
|
+
* Password generation
|
|
163
|
+
* ---------------------------------------------------------------------- */
|
|
164
|
+
|
|
165
|
+
const POOLS = {
|
|
166
|
+
lowercase: 'abcdefghijklmnopqrstuvwxyz',
|
|
167
|
+
uppercase: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
|
|
168
|
+
digits: '0123456789',
|
|
169
|
+
symbols: '!@#$%^&*()-_=+[]{};:,.?',
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Characters that are read wrong when a password is dictated over the phone or
|
|
174
|
+
* copied off a screen. Excluded by default, which is what `ambiguous: false`
|
|
175
|
+
* means.
|
|
176
|
+
*/
|
|
177
|
+
const AMBIGUOUS = new Set(['I', 'l', '1', 'O', '0', 'o', 'S', '5', 'B', '8']);
|
|
178
|
+
|
|
179
|
+
const getCrypto = () => {
|
|
180
|
+
const c = globalThis.crypto;
|
|
181
|
+
|
|
182
|
+
if (!c || typeof c.getRandomValues !== 'function') {
|
|
183
|
+
// Deliberately fatal. `Math.random` is seeded, predictable and utterly
|
|
184
|
+
// unfit to generate a credential — a silent fallback here would hand
|
|
185
|
+
// out guessable passwords that look exactly like good ones.
|
|
186
|
+
throw new Error(
|
|
187
|
+
'A secure random source is unavailable, so no password was generated.'
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return c;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* A uniform integer in `[0, max)`.
|
|
196
|
+
*
|
|
197
|
+
* Rejection sampling rather than `% max`, which skews towards the low end of
|
|
198
|
+
* the pool whenever the pool size does not divide 2^32 — a real, if small,
|
|
199
|
+
* bias in every character of every password.
|
|
200
|
+
*/
|
|
201
|
+
const randomBelow = (max) => {
|
|
202
|
+
const crypto = getCrypto();
|
|
203
|
+
const limit = Math.floor(0xffffffff / max) * max;
|
|
204
|
+
const buffer = new Uint32Array(1);
|
|
205
|
+
|
|
206
|
+
for (;;) {
|
|
207
|
+
crypto.getRandomValues(buffer);
|
|
208
|
+
if (buffer[0] < limit) return buffer[0] % max;
|
|
209
|
+
}
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
/** Fisher-Yates, drawing from the same source as the characters themselves. */
|
|
213
|
+
const shuffle = (items) => {
|
|
214
|
+
const out = items.slice();
|
|
215
|
+
|
|
216
|
+
for (let i = out.length - 1; i > 0; i -= 1) {
|
|
217
|
+
const j = randomBelow(i + 1);
|
|
218
|
+
[out[i], out[j]] = [out[j], out[i]];
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return out;
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
const MIN_LENGTH = 8;
|
|
225
|
+
const MAX_LENGTH = 128;
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Generate a password.
|
|
229
|
+
*
|
|
230
|
+
* Lowercase is always in play; the other three classes are opt-out. The result
|
|
231
|
+
* is guaranteed to contain at least one character from every enabled class —
|
|
232
|
+
* a password that happens to contain no digit will still be rejected by the
|
|
233
|
+
* next system that demands one, and "generate again until it looks right" is
|
|
234
|
+
* not a workflow.
|
|
235
|
+
*
|
|
236
|
+
* `ambiguous: false` (the default) excludes look-alike characters.
|
|
237
|
+
*/
|
|
238
|
+
export const generatePassword = ({
|
|
239
|
+
length = 20,
|
|
240
|
+
symbols = true,
|
|
241
|
+
digits = true,
|
|
242
|
+
uppercase = true,
|
|
243
|
+
ambiguous = false,
|
|
244
|
+
} = {}) => {
|
|
245
|
+
const enabled = ['lowercase'];
|
|
246
|
+
if (uppercase) enabled.push('uppercase');
|
|
247
|
+
if (digits) enabled.push('digits');
|
|
248
|
+
if (symbols) enabled.push('symbols');
|
|
249
|
+
|
|
250
|
+
const filter = (pool) =>
|
|
251
|
+
ambiguous
|
|
252
|
+
? pool.split('')
|
|
253
|
+
: pool.split('').filter((ch) => !AMBIGUOUS.has(ch));
|
|
254
|
+
|
|
255
|
+
const pools = enabled
|
|
256
|
+
.map((name) => filter(POOLS[name]))
|
|
257
|
+
.filter((pool) => pool.length > 0);
|
|
258
|
+
|
|
259
|
+
// Clamped, not thrown: the slider cannot produce anything outside this
|
|
260
|
+
// range, and a caller passing 4 wants the shortest usable password rather
|
|
261
|
+
// than an exception.
|
|
262
|
+
const target = Math.min(
|
|
263
|
+
MAX_LENGTH,
|
|
264
|
+
Math.max(MIN_LENGTH, pools.length, Math.floor(length) || 0)
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
// One from each class first, so the guarantee holds however short it is.
|
|
268
|
+
const chars = pools.map((pool) => pool[randomBelow(pool.length)]);
|
|
269
|
+
const all = pools.flat();
|
|
270
|
+
|
|
271
|
+
while (chars.length < target) {
|
|
272
|
+
chars.push(all[randomBelow(all.length)]);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Without this the first N characters are one-per-class in a fixed order.
|
|
276
|
+
return shuffle(chars).join('');
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/* -------------------------------------------------------------------------
|
|
280
|
+
* Password strength
|
|
281
|
+
* ---------------------------------------------------------------------- */
|
|
282
|
+
|
|
283
|
+
const STRENGTH_LABELS = ['Weak', 'Fair', 'Good', 'Strong', 'Excellent'];
|
|
284
|
+
|
|
285
|
+
/** Three or more of the same character in a row: `aaa`, `111`. */
|
|
286
|
+
const hasRun = (pw) => /(.)\1{2,}/.test(pw);
|
|
287
|
+
|
|
288
|
+
/** Three or more consecutive code points, either direction: `abc`, `321`. */
|
|
289
|
+
const hasSequence = (pw) => {
|
|
290
|
+
for (let i = 0; i + 2 < pw.length; i += 1) {
|
|
291
|
+
const a = pw.charCodeAt(i);
|
|
292
|
+
const b = pw.charCodeAt(i + 1);
|
|
293
|
+
const c = pw.charCodeAt(i + 2);
|
|
294
|
+
|
|
295
|
+
if (b - a === 1 && c - b === 1) return true;
|
|
296
|
+
if (a - b === 1 && b - c === 1) return true;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
return false;
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
/**
|
|
303
|
+
* Score a password out of 4.
|
|
304
|
+
*
|
|
305
|
+
* Length carries most of the weight because it genuinely does, class variety
|
|
306
|
+
* carries the rest, and the two obvious patterns cost a point each. This is a
|
|
307
|
+
* meter to steer someone away from `Password123`, not an entropy estimate —
|
|
308
|
+
* anything stronger belongs to the generator, which is one click away.
|
|
309
|
+
*/
|
|
310
|
+
export const scorePassword = (password) => {
|
|
311
|
+
const pw = String(password ?? '');
|
|
312
|
+
|
|
313
|
+
if (pw === '') return { score: 0, label: STRENGTH_LABELS[0] };
|
|
314
|
+
|
|
315
|
+
let points = 0;
|
|
316
|
+
|
|
317
|
+
if (pw.length >= 8) points += 1;
|
|
318
|
+
if (pw.length >= 12) points += 1;
|
|
319
|
+
if (pw.length >= 16) points += 1;
|
|
320
|
+
if (pw.length >= 20) points += 1;
|
|
321
|
+
|
|
322
|
+
const classes = [/[a-z]/, /[A-Z]/, /[0-9]/, /[^a-zA-Z0-9]/].filter((re) =>
|
|
323
|
+
re.test(pw)
|
|
324
|
+
).length;
|
|
325
|
+
|
|
326
|
+
if (classes >= 2) points += 1;
|
|
327
|
+
if (classes >= 3) points += 1;
|
|
328
|
+
if (classes >= 4) points += 1;
|
|
329
|
+
|
|
330
|
+
if (hasRun(pw)) points -= 1;
|
|
331
|
+
if (hasSequence(pw)) points -= 1;
|
|
332
|
+
|
|
333
|
+
// points runs 0..7; these thresholds map it onto the five labels.
|
|
334
|
+
let score;
|
|
335
|
+
if (points <= 1) score = 0;
|
|
336
|
+
else if (points <= 3) score = 1;
|
|
337
|
+
else if (points <= 4) score = 2;
|
|
338
|
+
else if (points <= 6) score = 3;
|
|
339
|
+
else score = 4;
|
|
340
|
+
|
|
341
|
+
return { score, label: STRENGTH_LABELS[score] };
|
|
342
|
+
};
|
|
343
|
+
|
|
344
|
+
/* -------------------------------------------------------------------------
|
|
345
|
+
* Codes and clocks
|
|
346
|
+
* ---------------------------------------------------------------------- */
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Space a one-time code so it can be read off the screen and typed.
|
|
350
|
+
*
|
|
351
|
+
* Even lengths split down the middle (`482119` -> `482 119`, the grouping
|
|
352
|
+
* every authenticator app uses); odd lengths group in threes from the left.
|
|
353
|
+
*/
|
|
354
|
+
export const formatOtp = (code) => {
|
|
355
|
+
const digits = String(code ?? '').replace(/\s/g, '');
|
|
356
|
+
|
|
357
|
+
if (digits === '') return '';
|
|
358
|
+
if (digits.length % 2 === 0) {
|
|
359
|
+
const half = digits.length / 2;
|
|
360
|
+
return `${digits.slice(0, half)} ${digits.slice(half)}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return digits.match(/.{1,3}/g).join(' ');
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
/**
|
|
367
|
+
* Seconds left in the current TOTP window.
|
|
368
|
+
*
|
|
369
|
+
* Returns `period` exactly on a boundary — a whole window remains — and never
|
|
370
|
+
* 0, so a countdown driven by this never renders an expired-looking code that
|
|
371
|
+
* is in fact still valid.
|
|
372
|
+
*/
|
|
373
|
+
export const secondsUntilBoundary = (period = OTP_DEFAULTS.period, now = Date.now()) => {
|
|
374
|
+
const size = Number.isFinite(period) && period > 0 ? Math.floor(period) : OTP_DEFAULTS.period;
|
|
375
|
+
const seconds = Math.floor(now / 1000);
|
|
376
|
+
|
|
377
|
+
return size - (seconds % size);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
/* -------------------------------------------------------------------------
|
|
381
|
+
* Keyboard shortcut
|
|
382
|
+
* ---------------------------------------------------------------------- */
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Best-effort platform sniff, used only to decide what `mod` means and which
|
|
386
|
+
* glyph to print. Guarded for non-browser environments.
|
|
387
|
+
*/
|
|
388
|
+
export const isMacPlatform = () => {
|
|
389
|
+
const nav = globalThis.navigator;
|
|
390
|
+
|
|
391
|
+
if (!nav) return false;
|
|
392
|
+
|
|
393
|
+
const source = String(nav.userAgentData?.platform || nav.platform || nav.userAgent || '');
|
|
394
|
+
|
|
395
|
+
return /mac|iphone|ipad|ipod/i.test(source);
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const parseShortcut = (shortcut) => {
|
|
399
|
+
const parts = String(shortcut ?? '')
|
|
400
|
+
.toLowerCase()
|
|
401
|
+
.split('+')
|
|
402
|
+
.map((part) => part.trim())
|
|
403
|
+
.filter(Boolean);
|
|
404
|
+
|
|
405
|
+
return {
|
|
406
|
+
mod: parts.includes('mod'),
|
|
407
|
+
shift: parts.includes('shift'),
|
|
408
|
+
alt: parts.includes('alt') || parts.includes('option'),
|
|
409
|
+
ctrl: parts.includes('ctrl') || parts.includes('control'),
|
|
410
|
+
meta: parts.includes('meta') || parts.includes('cmd'),
|
|
411
|
+
key: parts[parts.length - 1] || '',
|
|
412
|
+
};
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Does this keydown event match a shortcut like `mod+shift+k`?
|
|
417
|
+
*
|
|
418
|
+
* `mod` is Command on a Mac and Control everywhere else — the convention every
|
|
419
|
+
* cross-platform app follows, and the reason a hard-coded `ctrl+` shortcut
|
|
420
|
+
* fights the OS on one of the two platforms.
|
|
421
|
+
*
|
|
422
|
+
* The modifiers the shortcut does *not* name must be absent, so `mod+shift+k`
|
|
423
|
+
* is not fired by `mod+shift+alt+k` — that is somebody else's shortcut. That
|
|
424
|
+
* strictness is also why an array is accepted: `⌘K` and `⌘⇧K` are two
|
|
425
|
+
* shortcuts, not one with an optional modifier, and both should open the
|
|
426
|
+
* vault.
|
|
427
|
+
*
|
|
428
|
+
* `isMac` is a parameter rather than a lookup so this stays pure and testable;
|
|
429
|
+
* it defaults to sniffing the platform.
|
|
430
|
+
*/
|
|
431
|
+
export const matchesShortcut = (
|
|
432
|
+
event,
|
|
433
|
+
shortcut = 'mod+shift+k',
|
|
434
|
+
isMac = isMacPlatform()
|
|
435
|
+
) => {
|
|
436
|
+
if (!event || !event.key) return false;
|
|
437
|
+
|
|
438
|
+
if (Array.isArray(shortcut)) {
|
|
439
|
+
return shortcut.some((one) => matchesShortcut(event, one, isMac));
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const wanted = parseShortcut(shortcut);
|
|
443
|
+
|
|
444
|
+
if (wanted.key === '') return false;
|
|
445
|
+
// `event.key` is layout-aware and already lowercase for letters unless
|
|
446
|
+
// shift is held, in which case it arrives uppercase.
|
|
447
|
+
if (String(event.key).toLowerCase() !== wanted.key) return false;
|
|
448
|
+
|
|
449
|
+
const wantCtrl = wanted.ctrl || (wanted.mod && !isMac);
|
|
450
|
+
const wantMeta = wanted.meta || (wanted.mod && isMac);
|
|
451
|
+
|
|
452
|
+
return (
|
|
453
|
+
Boolean(event.ctrlKey) === wantCtrl &&
|
|
454
|
+
Boolean(event.metaKey) === wantMeta &&
|
|
455
|
+
Boolean(event.shiftKey) === wanted.shift &&
|
|
456
|
+
Boolean(event.altKey) === wanted.alt
|
|
457
|
+
);
|
|
458
|
+
};
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* `⌘⇧K` on a Mac, `Ctrl+Shift+K` elsewhere — for the hint line.
|
|
462
|
+
*
|
|
463
|
+
* Given several shortcuts, the first is the one printed: it is the one the
|
|
464
|
+
* hint is teaching, and a footer listing every accepted chord teaches nothing.
|
|
465
|
+
*/
|
|
466
|
+
export const shortcutLabel = (shortcut = 'mod+shift+k', isMac = isMacPlatform()) => {
|
|
467
|
+
if (Array.isArray(shortcut)) {
|
|
468
|
+
return shortcut.length ? shortcutLabel(shortcut[0], isMac) : '';
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const wanted = parseShortcut(shortcut);
|
|
472
|
+
const key = wanted.key.toUpperCase();
|
|
473
|
+
|
|
474
|
+
if (isMac) {
|
|
475
|
+
return `${wanted.ctrl ? '⌃' : ''}${wanted.alt ? '⌥' : ''}${
|
|
476
|
+
wanted.mod || wanted.meta ? '⌘' : ''
|
|
477
|
+
}${wanted.shift ? '⇧' : ''}${key}`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const parts = [];
|
|
481
|
+
if (wanted.mod || wanted.ctrl) parts.push('Ctrl');
|
|
482
|
+
if (wanted.alt) parts.push('Alt');
|
|
483
|
+
if (wanted.shift) parts.push('Shift');
|
|
484
|
+
if (wanted.meta && !wanted.mod) parts.push('Meta');
|
|
485
|
+
parts.push(key);
|
|
486
|
+
|
|
487
|
+
return parts.join('+');
|
|
488
|
+
};
|
|
489
|
+
|
|
490
|
+
/* -------------------------------------------------------------------------
|
|
491
|
+
* Small display helpers
|
|
492
|
+
* ---------------------------------------------------------------------- */
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* The host of a stored URL, for the second line of a result row.
|
|
496
|
+
*
|
|
497
|
+
* Entries are typed by hand, so `mybank.com.au` arrives as often as a full
|
|
498
|
+
* URL; a missing scheme is assumed to be https rather than discarded. `www.`
|
|
499
|
+
* is dropped because it is never the distinguishing part.
|
|
500
|
+
*/
|
|
501
|
+
export const hostFromUrl = (url) => {
|
|
502
|
+
const raw = String(url ?? '').trim();
|
|
503
|
+
|
|
504
|
+
if (raw === '') return '';
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const parsed = new URL(/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) ? raw : `https://${raw}`);
|
|
508
|
+
|
|
509
|
+
return parsed.hostname.replace(/^www\./i, '');
|
|
510
|
+
} catch {
|
|
511
|
+
return raw;
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
|
|
515
|
+
/** A URL safe to hand to `window.open`, or '' when there is nothing to open. */
|
|
516
|
+
export const openableUrl = (url) => {
|
|
517
|
+
const raw = String(url ?? '').trim();
|
|
518
|
+
|
|
519
|
+
if (raw === '') return '';
|
|
520
|
+
|
|
521
|
+
// Anything that is not http(s) — `javascript:` above all — is refused
|
|
522
|
+
// rather than opened. These values are typed by staff and stored verbatim.
|
|
523
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(raw)) {
|
|
524
|
+
return /^https?:\/\//i.test(raw) ? raw : '';
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
return `https://${raw}`;
|
|
528
|
+
};
|
|
529
|
+
|
|
530
|
+
/** `a, b , a` -> `['a', 'b']`. Order kept, blanks and duplicates dropped. */
|
|
531
|
+
export const parseTags = (input) => {
|
|
532
|
+
const source = Array.isArray(input) ? input : String(input ?? '').split(',');
|
|
533
|
+
const seen = new Set();
|
|
534
|
+
const out = [];
|
|
535
|
+
|
|
536
|
+
source.forEach((tag) => {
|
|
537
|
+
const clean = String(tag ?? '').trim();
|
|
538
|
+
const key = clean.toLowerCase();
|
|
539
|
+
|
|
540
|
+
if (clean === '' || seen.has(key)) return;
|
|
541
|
+
|
|
542
|
+
seen.add(key);
|
|
543
|
+
out.push(clean);
|
|
544
|
+
});
|
|
545
|
+
|
|
546
|
+
return out;
|
|
547
|
+
};
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React, { useCallback } from 'react';
|
|
2
|
+
import { useInRouterContext, useLocation, useNavigate } from 'react-router-dom';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Give a component a `navigate` that works whether or not it is inside a
|
|
6
|
+
* Router.
|
|
7
|
+
*
|
|
8
|
+
* The vault's surfaces mount in two quite different places: the CRM header,
|
|
9
|
+
* which is inside a `<BrowserRouter>`, and the standalone fixture, which is
|
|
10
|
+
* not. `useNavigate` throws outside a Router and hooks cannot be called
|
|
11
|
+
* conditionally, so the choice is made by rendering one of two components —
|
|
12
|
+
* each of which calls its own hooks unconditionally. A component does not
|
|
13
|
+
* move in or out of a Router during its life, so the branch is stable.
|
|
14
|
+
*
|
|
15
|
+
* The wrapped component receives `navigate(to)` and `locationSearch`.
|
|
16
|
+
*/
|
|
17
|
+
export const withVaultNavigation = (Inner) => {
|
|
18
|
+
const Routed = (props) => {
|
|
19
|
+
const navigate = useNavigate();
|
|
20
|
+
const location = useLocation();
|
|
21
|
+
|
|
22
|
+
return (
|
|
23
|
+
<Inner {...props} navigate={navigate} locationSearch={location.search} />
|
|
24
|
+
);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const Plain = (props) => {
|
|
28
|
+
// A full page load, which is the only thing available here — and the
|
|
29
|
+
// right behaviour for a link out of a standalone fixture.
|
|
30
|
+
const navigate = useCallback((to) => {
|
|
31
|
+
if (typeof window !== 'undefined') window.location.href = to;
|
|
32
|
+
}, []);
|
|
33
|
+
|
|
34
|
+
return (
|
|
35
|
+
<Inner
|
|
36
|
+
{...props}
|
|
37
|
+
navigate={navigate}
|
|
38
|
+
locationSearch={
|
|
39
|
+
typeof window !== 'undefined' ? window.location.search : ''
|
|
40
|
+
}
|
|
41
|
+
/>
|
|
42
|
+
);
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const name = Inner.displayName || Inner.name || 'Component';
|
|
46
|
+
|
|
47
|
+
Routed.displayName = `Routed(${name})`;
|
|
48
|
+
Plain.displayName = `Plain(${name})`;
|
|
49
|
+
|
|
50
|
+
const Wrapper = (props) =>
|
|
51
|
+
useInRouterContext() ? <Routed {...props} /> : <Plain {...props} />;
|
|
52
|
+
|
|
53
|
+
Wrapper.displayName = `withVaultNavigation(${name})`;
|
|
54
|
+
|
|
55
|
+
return Wrapper;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export default withVaultNavigation;
|