@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.
Files changed (97) hide show
  1. package/README.md +1623 -18
  2. package/package.json +10 -4
  3. package/src/components/DataGrid.jsx +171 -37
  4. package/src/components/Fetch.jsx +80 -3
  5. package/src/components/Form.jsx +9 -0
  6. package/src/components/Navigation.jsx +109 -50
  7. package/src/components/Notification.jsx +279 -9
  8. package/src/components/TableFilter.jsx +14 -1
  9. package/src/components/auth/AuthBrandPanel.jsx +43 -0
  10. package/src/components/auth/AuthLoading.jsx +78 -0
  11. package/src/components/auth/AuthShell.jsx +59 -0
  12. package/src/components/auth/ClientAuth.jsx +161 -0
  13. package/src/components/auth/ClientAuthFrame.jsx +59 -0
  14. package/src/components/auth/ClientLogin.jsx +266 -55
  15. package/src/components/auth/ClientOTPVerify.jsx +587 -115
  16. package/src/components/auth/ImpersonateGate.jsx +254 -0
  17. package/src/components/auth/Login.jsx +134 -41
  18. package/src/components/auth/LogoutScreen.jsx +112 -0
  19. package/src/components/auth/Reset.jsx +134 -77
  20. package/src/components/auth/TwoFactorAuth.jsx +475 -297
  21. package/src/components/auth/Verify.jsx +237 -126
  22. package/src/components/auth/authEndpoints.js +105 -0
  23. package/src/components/auth/authFont.js +23 -0
  24. package/src/components/auth/authHelpers.js +465 -0
  25. package/src/components/auth/clientAuthProtocols.js +240 -0
  26. package/src/components/auth/useOptionalRouter.js +49 -0
  27. package/src/components/callQueue/CallQueuePop.jsx +1502 -0
  28. package/src/components/callQueue/CallQueueSettings.jsx +508 -0
  29. package/src/components/callQueue/callQueueHelpers.js +280 -0
  30. package/src/components/callQueue/callQueueSettingsHelpers.js +75 -0
  31. package/src/components/columns/ColumnRenderers.jsx +53 -7
  32. package/src/components/generic/GenericAuth.jsx +163 -96
  33. package/src/components/generic/GenericDetail.jsx +215 -90
  34. package/src/components/generic/GenericIndex.jsx +20 -47
  35. package/src/components/generic/GenericMain.jsx +5 -0
  36. package/src/components/generic/GroupedReportRenderer.jsx +1 -5
  37. package/src/components/generic/StandardModal.jsx +17 -5
  38. package/src/components/generic/reportSemanticSteps/SemanticEntityStep.jsx +1 -6
  39. package/src/components/generic/reportSemanticSteps/SemanticFieldsStep.jsx +4 -11
  40. package/src/components/generic/reportSemanticSteps/SemanticFiltersStep.jsx +9 -27
  41. package/src/components/generic/reportSemanticSteps/SemanticGroupingStep.jsx +5 -13
  42. package/src/components/generic/reportSemanticSteps/SemanticParameterPrompt.jsx +5 -13
  43. package/src/components/generic/reportSemanticSteps/SemanticPreviewStep.jsx +14 -31
  44. package/src/components/generic/reportSemanticSteps/SemanticRelationsStep.jsx +3 -9
  45. package/src/components/generic/reportSemanticSteps/SemanticValueInput.jsx +2 -15
  46. package/src/components/navActive.js +60 -0
  47. package/src/components/notify/desktopNotifications.js +256 -0
  48. package/src/components/sketch/SketchField.jsx +12 -2
  49. package/src/components/sms/SmsComposeModal.jsx +495 -0
  50. package/src/components/sms/SmsInbox.jsx +596 -0
  51. package/src/components/sms/SmsInboxBadge.jsx +468 -0
  52. package/src/components/sms/SmsLineSettings.jsx +854 -0
  53. package/src/components/sms/SmsThreadPanel.jsx +1184 -0
  54. package/src/components/sms/smsEndpoints.js +56 -0
  55. package/src/components/sms/smsHelpers.js +1075 -0
  56. package/src/components/sms/smsLiveState.js +132 -0
  57. package/src/components/sms/useSmsLive.js +307 -0
  58. package/src/components/styles/CallQueuePop.module.scss +646 -0
  59. package/src/components/styles/CallQueueSettings.module.scss +460 -0
  60. package/src/components/styles/ClientAuth.module.scss +229 -0
  61. package/src/components/styles/GenericDetail.module.scss +153 -26
  62. package/src/components/styles/GenericIndex.module.scss +13 -0
  63. package/src/components/styles/GenericMain.module.scss +19 -2
  64. package/src/components/styles/ImpersonateGate.module.scss +85 -0
  65. package/src/components/styles/Login.module.scss +13 -122
  66. package/src/components/styles/LogoutScreen.module.scss +89 -0
  67. package/src/components/styles/Navigation.module.scss +509 -223
  68. package/src/components/styles/Notification.module.scss +103 -0
  69. package/src/components/styles/Reset.module.scss +52 -186
  70. package/src/components/styles/Sms.module.scss +2382 -0
  71. package/src/components/styles/TableFilter.module.scss +118 -98
  72. package/src/components/styles/TwoFactorAuth.module.scss +115 -176
  73. package/src/components/styles/Vault.module.scss +1983 -0
  74. package/src/components/styles/Verify.module.scss +57 -180
  75. package/src/components/styles/_authBrandPanel.scss +163 -0
  76. package/src/components/styles/_authShell.scss +369 -0
  77. package/src/components/utils/buildEnv.js +67 -0
  78. package/src/components/utils/displayValue.js +94 -0
  79. package/src/components/utils/useDensity.js +345 -9
  80. package/src/components/vault/OtpChip.jsx +208 -0
  81. package/src/components/vault/PasswordGenerator.jsx +145 -0
  82. package/src/components/vault/QrScanner.jsx +326 -0
  83. package/src/components/vault/VaultAccessLog.jsx +225 -0
  84. package/src/components/vault/VaultConfirmPanel.jsx +113 -0
  85. package/src/components/vault/VaultEntryForm.jsx +740 -0
  86. package/src/components/vault/VaultManager.jsx +1000 -0
  87. package/src/components/vault/VaultQuickSearch.jsx +681 -0
  88. package/src/components/vault/qrDecode.js +257 -0
  89. package/src/components/vault/useDebouncedValue.js +22 -0
  90. package/src/components/vault/useVaultReveal.js +160 -0
  91. package/src/components/vault/vaultClipboard.js +33 -0
  92. package/src/components/vault/vaultEndpoints.js +40 -0
  93. package/src/components/vault/vaultFit.js +116 -0
  94. package/src/components/vault/vaultHelpers.js +547 -0
  95. package/src/components/vault/vaultNavigation.jsx +58 -0
  96. package/src/components/vault/vaultOtp.js +105 -0
  97. 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;