@vex-chat/spire 4.0.0 → 4.1.1

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.
@@ -23,70 +23,138 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
23
23
  <style>
24
24
  :root {
25
25
  color-scheme: dark;
26
- --bg: #080b0d;
27
- --panel: #10161a;
28
- --panel-border: #27323a;
29
- --text: #eef6fb;
30
- --muted: #9fb2be;
31
- --accent: #a8c8df;
32
- --accent-strong: #d3ebfb;
33
- --danger: #ff776d;
26
+ --bg: #141519;
27
+ --panel: #202126;
28
+ --border: #2b2d33;
29
+ --border-strong: #41434b;
30
+ --text: #f2f3f5;
31
+ --text-secondary: #dbdee1;
32
+ --muted: #b5bac1;
33
+ --faint: #80848e;
34
+ --accent: #e5484d;
35
+ --accent-hover: #f05b60;
36
+ --accent-soft: rgba(229, 72, 77, 0.16);
37
+ --danger: #f23f42;
38
+ --success: #3ba55c;
34
39
  }
35
40
 
36
41
  * {
37
42
  box-sizing: border-box;
38
43
  }
39
44
 
40
- html,
41
- body {
45
+ html {
42
46
  min-height: 100%;
43
47
  }
44
48
 
45
49
  body {
46
50
  align-items: center;
47
- background:
48
- radial-gradient(circle at 50% 0%, rgba(168, 200, 223, 0.12), transparent 36rem),
49
- var(--bg);
51
+ background: var(--bg);
50
52
  color: var(--text);
51
53
  display: flex;
52
54
  font-family:
53
55
  Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
54
56
  justify-content: center;
55
57
  margin: 0;
56
- padding: 24px;
58
+ min-height: 100vh;
59
+ padding: 20px;
57
60
  }
58
61
 
59
62
  main {
60
- background: rgba(16, 22, 26, 0.95);
61
- border: 1px solid var(--panel-border);
63
+ background: var(--panel);
64
+ border: 1px solid var(--border);
62
65
  border-radius: 8px;
63
- box-shadow: 0 24px 80px rgba(0, 0, 0, 0.42);
64
- max-width: 460px;
65
- padding: 28px;
66
+ box-shadow: 0 18px 56px rgba(0, 0, 0, 0.34);
67
+ max-width: 420px;
68
+ overflow: hidden;
66
69
  width: 100%;
67
70
  }
68
71
 
69
- .mark {
70
- color: var(--accent);
71
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
72
- font-size: 34px;
73
- height: 44px;
74
- line-height: 44px;
75
- margin-bottom: 18px;
76
- width: 44px;
72
+ .titlebar {
73
+ align-items: center;
74
+ border-bottom: 1px solid var(--border);
75
+ display: flex;
76
+ min-height: 52px;
77
+ padding: 0 20px;
78
+ }
79
+
80
+ .wordmark {
81
+ color: var(--text);
82
+ font-family: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
83
+ font-size: 17px;
84
+ font-weight: 700;
85
+ line-height: 1;
86
+ }
87
+
88
+ .titlebar-divider {
89
+ background: var(--border-strong);
90
+ height: 18px;
91
+ margin: 0 11px;
92
+ width: 1px;
93
+ }
94
+
95
+ .titlebar-context {
96
+ color: var(--muted);
97
+ font-size: 13px;
98
+ font-weight: 600;
99
+ }
100
+
101
+ .secure-context {
102
+ align-items: center;
103
+ color: var(--faint);
104
+ display: inline-flex;
105
+ font-size: 12px;
106
+ gap: 5px;
107
+ margin-left: auto;
108
+ }
109
+
110
+ .secure-context svg {
111
+ height: 14px;
112
+ width: 14px;
113
+ }
114
+
115
+ .content {
116
+ padding: 26px 26px 24px;
117
+ }
118
+
119
+ .passkey-icon {
120
+ align-items: center;
121
+ background: var(--accent-soft);
122
+ border: 1px solid rgba(229, 72, 77, 0.28);
123
+ border-radius: 8px;
124
+ color: var(--accent-hover);
125
+ display: flex;
126
+ height: 48px;
127
+ justify-content: center;
128
+ margin-bottom: 20px;
129
+ width: 48px;
130
+ }
131
+
132
+ .passkey-icon svg {
133
+ height: 24px;
134
+ width: 24px;
135
+ }
136
+
137
+ .eyebrow {
138
+ color: var(--faint);
139
+ font-size: 12px;
140
+ font-weight: 700;
141
+ line-height: 1.3;
142
+ margin: 0 0 7px;
143
+ text-transform: uppercase;
77
144
  }
78
145
 
79
146
  h1 {
80
- font-size: 24px;
81
- font-weight: 650;
147
+ font-family: "Space Grotesk", Inter, ui-sans-serif, system-ui, sans-serif;
148
+ font-size: 23px;
149
+ font-weight: 700;
82
150
  letter-spacing: 0;
83
- line-height: 1.15;
84
- margin: 0 0 10px;
151
+ line-height: 1.2;
152
+ margin: 0 0 9px;
85
153
  }
86
154
 
87
- p {
155
+ .description {
88
156
  color: var(--muted);
89
- font-size: 15px;
157
+ font-size: 14px;
90
158
  line-height: 1.5;
91
159
  margin: 0;
92
160
  }
@@ -95,99 +163,373 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
95
163
  align-items: center;
96
164
  appearance: none;
97
165
  background: var(--accent);
98
- border: 0;
166
+ border: 1px solid var(--accent);
99
167
  border-radius: 8px;
100
- color: #071014;
168
+ color: #ffffff;
101
169
  cursor: pointer;
102
170
  display: inline-flex;
103
171
  font: inherit;
104
- font-weight: 700;
172
+ font-size: 14px;
173
+ font-weight: 650;
174
+ gap: 9px;
105
175
  justify-content: center;
106
- margin-top: 24px;
107
- min-height: 46px;
176
+ margin-top: 22px;
177
+ min-height: 44px;
108
178
  padding: 0 18px;
179
+ transition:
180
+ background 120ms ease,
181
+ border-color 120ms ease;
109
182
  width: 100%;
110
183
  }
111
184
 
185
+ button:hover:not(:disabled) {
186
+ background: var(--accent-hover);
187
+ border-color: var(--accent-hover);
188
+ }
189
+
190
+ button:focus-visible {
191
+ outline: 2px solid var(--text);
192
+ outline-offset: 3px;
193
+ }
194
+
112
195
  button:disabled {
113
196
  cursor: wait;
114
- opacity: 0.72;
197
+ opacity: 0.78;
198
+ }
199
+
200
+ .spinner {
201
+ animation: spin 760ms linear infinite;
202
+ border: 2px solid rgba(255, 255, 255, 0.4);
203
+ border-radius: 50%;
204
+ border-top-color: #ffffff;
205
+ display: none;
206
+ height: 15px;
207
+ width: 15px;
208
+ }
209
+
210
+ button[data-busy="true"] .spinner {
211
+ display: block;
212
+ }
213
+
214
+ .helper {
215
+ color: var(--faint);
216
+ font-size: 12px;
217
+ line-height: 1.45;
218
+ margin: 11px 2px 0;
219
+ text-align: center;
115
220
  }
116
221
 
117
222
  .status {
118
- border-top: 1px solid var(--panel-border);
223
+ align-items: flex-start;
224
+ border-top: 1px solid var(--border);
119
225
  color: var(--muted);
120
- font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
121
- font-size: 13px;
226
+ display: flex;
227
+ font-size: 12px;
228
+ gap: 9px;
122
229
  line-height: 1.45;
123
- margin-top: 22px;
230
+ margin-top: 20px;
124
231
  min-height: 20px;
125
- padding-top: 18px;
232
+ padding-top: 16px;
126
233
  word-break: break-word;
127
234
  }
128
235
 
129
- .error {
236
+ .status-indicator {
237
+ background: var(--faint);
238
+ border-radius: 2px;
239
+ flex: 0 0 auto;
240
+ height: 7px;
241
+ margin-top: 5px;
242
+ width: 7px;
243
+ }
244
+
245
+ main[data-state="busy"] .status-indicator {
246
+ animation: pulse 1.1s ease-in-out infinite;
247
+ background: var(--accent-hover);
248
+ }
249
+
250
+ main[data-state="error"] .status {
130
251
  color: var(--danger);
131
252
  }
132
253
 
133
- .success {
134
- color: var(--accent-strong);
254
+ main[data-state="error"] .status-indicator {
255
+ background: var(--danger);
256
+ }
257
+
258
+ main[data-state="success"] .status {
259
+ color: var(--text-secondary);
260
+ }
261
+
262
+ main[data-state="success"] .status-indicator {
263
+ background: var(--success);
264
+ }
265
+
266
+ .completion {
267
+ align-items: center;
268
+ color: var(--text-secondary);
269
+ display: flex;
270
+ font-size: 13px;
271
+ gap: 9px;
272
+ line-height: 1.4;
273
+ margin-top: 22px;
274
+ }
275
+
276
+ .completion svg {
277
+ color: var(--success);
278
+ flex: 0 0 auto;
279
+ height: 20px;
280
+ width: 20px;
281
+ }
282
+
283
+ [hidden] {
284
+ display: none !important;
285
+ }
286
+
287
+ @keyframes spin {
288
+ to {
289
+ transform: rotate(360deg);
290
+ }
291
+ }
292
+
293
+ @keyframes pulse {
294
+ 50% {
295
+ opacity: 0.4;
296
+ }
297
+ }
298
+
299
+ @media (max-width: 460px) {
300
+ body {
301
+ padding: 12px;
302
+ }
303
+
304
+ .content {
305
+ padding: 24px 22px 22px;
306
+ }
307
+
308
+ .titlebar {
309
+ padding: 0 18px;
310
+ }
311
+ }
312
+
313
+ @media (max-height: 620px) {
314
+ body {
315
+ align-items: flex-start;
316
+ }
317
+
318
+ .content {
319
+ padding-bottom: 20px;
320
+ padding-top: 22px;
321
+ }
322
+
323
+ .passkey-icon {
324
+ margin-bottom: 16px;
325
+ }
326
+
327
+ button {
328
+ margin-top: 18px;
329
+ }
330
+
331
+ .status {
332
+ margin-top: 16px;
333
+ }
334
+ }
335
+
336
+ @media (prefers-reduced-motion: reduce) {
337
+ *,
338
+ *::before,
339
+ *::after {
340
+ animation-duration: 0.01ms !important;
341
+ animation-iteration-count: 1 !important;
342
+ scroll-behavior: auto !important;
343
+ transition-duration: 0.01ms !important;
344
+ }
135
345
  }
136
346
  </style>
137
347
  </head>
138
348
  <body>
139
- <main>
140
- <div class="mark">◈</div>
141
- <h1 id="title">Continue with your passkey.</h1>
142
- <p id="copy">Use the passkey saved in this browser or on a nearby device to finish signing in from the Vex CLI.</p>
143
- <button id="action" type="button">Continue with passkey</button>
144
- <div class="status" id="status">Waiting for passkey.</div>
349
+ <main id="surface" data-state="ready">
350
+ <header class="titlebar">
351
+ <span class="wordmark">vex</span>
352
+ <span class="titlebar-divider" aria-hidden="true"></span>
353
+ <span class="titlebar-context" id="titlebar-context">Passkey</span>
354
+ <span class="secure-context">
355
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
356
+ <rect width="18" height="11" x="3" y="11" rx="2"></rect>
357
+ <path d="M7 11V7a5 5 0 0 1 10 0v4"></path>
358
+ </svg>
359
+ Secure
360
+ </span>
361
+ </header>
362
+ <section class="content">
363
+ <div class="passkey-icon">
364
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
365
+ <circle cx="7.5" cy="15.5" r="5.5"></circle>
366
+ <path d="m21 2-9.6 9.6"></path>
367
+ <path d="m15.5 7.5 3 3L22 7l-3-3"></path>
368
+ </svg>
369
+ </div>
370
+ <p class="eyebrow" id="eyebrow">Secure sign in</p>
371
+ <h1 id="title">Continue with your passkey</h1>
372
+ <p class="description" id="copy">Use a passkey saved in this browser or on a nearby device to continue in Vex.</p>
373
+ <button id="action" type="button" data-busy="false">
374
+ <span class="spinner" aria-hidden="true"></span>
375
+ <span id="action-label">Continue with passkey</span>
376
+ </button>
377
+ <p class="helper" id="helper">Your password remains available as a sign-in method.</p>
378
+ <div class="completion" id="completion" hidden>
379
+ <svg aria-hidden="true" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.25" stroke-linecap="round" stroke-linejoin="round">
380
+ <path d="M20 6 9 17l-5-5"></path>
381
+ </svg>
382
+ <span id="completion-copy">You can close this page and return to Vex.</span>
383
+ </div>
384
+ <div class="status" id="status" role="status" aria-live="polite">
385
+ <span class="status-indicator" aria-hidden="true"></span>
386
+ <span id="status-copy">Ready for your passkey.</span>
387
+ </div>
388
+ </section>
145
389
  </main>
146
390
  <script>
147
391
  "use strict";
148
392
 
149
393
  var params = new URLSearchParams(window.location.hash.slice(1));
394
+ if (window.location.hash) {
395
+ window.history.replaceState(
396
+ null,
397
+ "",
398
+ window.location.pathname + window.location.search,
399
+ );
400
+ }
150
401
  var action = document.getElementById("action");
151
- var statusEl = document.getElementById("status");
402
+ var actionLabel = document.getElementById("action-label");
403
+ var completion = document.getElementById("completion");
404
+ var completionCopy = document.getElementById("completion-copy");
152
405
  var titleEl = document.getElementById("title");
153
406
  var copyEl = document.getElementById("copy");
407
+ var eyebrowEl = document.getElementById("eyebrow");
408
+ var helperEl = document.getElementById("helper");
409
+ var statusCopy = document.getElementById("status-copy");
410
+ var surface = document.getElementById("surface");
411
+ var titlebarContext = document.getElementById("titlebar-context");
154
412
  var mode = params.get("mode") || "recover";
155
413
  var startupError = null;
156
414
  var apiBase = window.location.origin;
415
+ var completionCallback = null;
416
+ var actionDefaultLabel = "Continue with passkey";
157
417
  var busy = false;
418
+ var completed = false;
419
+
420
+ configurePage();
158
421
 
159
422
  try {
160
423
  apiBase = resolveTrustedApiBase(params.get("api"));
424
+ completionCallback = resolveCompletionCallback(
425
+ params.get("callback"),
426
+ );
427
+ validateLinkParams();
161
428
  } catch (err) {
162
429
  startupError = err;
163
430
  }
164
431
 
165
- if (mode === "register") {
166
- titleEl.textContent = "Create a passkey for Vex.";
167
- copyEl.textContent = "This adds a passkey to your Vex account.";
168
- action.textContent = "Create passkey";
169
- setStatus("Ready to create passkey.");
170
- } else {
171
- setStatus("Ready to verify passkey.");
172
- }
173
-
174
432
  if (startupError) {
175
433
  setStatus(errorMessage(startupError), "error");
176
- action.disabled = true;
434
+ action.hidden = true;
435
+ helperEl.textContent =
436
+ "Open a new passkey request from Vex and try again.";
177
437
  }
178
438
 
179
439
  action.addEventListener("click", function () {
180
440
  void run();
181
441
  });
182
442
 
443
+ function configurePage() {
444
+ document.body.dataset.mode = mode;
445
+ if (mode === "register" || mode === "register-handoff") {
446
+ document.title = "Add a passkey - Vex";
447
+ titlebarContext.textContent = "Add passkey";
448
+ eyebrowEl.textContent = "Account security";
449
+ titleEl.textContent = "Create a passkey";
450
+ copyEl.textContent =
451
+ "Use Face ID, Touch ID, your device PIN, or a security key to add a faster sign-in method.";
452
+ helperEl.textContent =
453
+ "Your password remains available as a sign-in method.";
454
+ actionDefaultLabel = "Create passkey";
455
+ actionLabel.textContent = actionDefaultLabel;
456
+ setStatus("Ready to create your passkey.");
457
+ return;
458
+ }
459
+ if (mode === "authenticate-handoff") {
460
+ document.title = "Sign in with a passkey - Vex";
461
+ titlebarContext.textContent = "Sign in";
462
+ eyebrowEl.textContent = "Desktop sign in";
463
+ titleEl.textContent = "Use your passkey";
464
+ copyEl.textContent =
465
+ "Choose a passkey saved in this browser, on this device, or on a nearby device.";
466
+ helperEl.textContent =
467
+ "Vex will finish signing in automatically after verification.";
468
+ setStatus("Ready to verify your passkey.");
469
+ return;
470
+ }
471
+ document.title = "Recover a device - Vex";
472
+ titlebarContext.textContent = "Device recovery";
473
+ eyebrowEl.textContent = "Trusted device";
474
+ titleEl.textContent = "Verify your passkey";
475
+ copyEl.textContent =
476
+ "Use an account passkey to approve this Vex device.";
477
+ helperEl.textContent =
478
+ "Only continue if you started this request from your device.";
479
+ setStatus("Ready to verify your passkey.");
480
+ }
481
+
482
+ function validateLinkParams() {
483
+ if (mode === "register") {
484
+ requiredParam("token");
485
+ requiredParam("user");
486
+ requiredParam("username");
487
+ return;
488
+ }
489
+ if (mode === "register-handoff" || mode === "authenticate-handoff") {
490
+ requiredParam("token");
491
+ requiredParam("request");
492
+ return;
493
+ }
494
+ requiredParam("username");
495
+ requiredParam("request");
496
+ }
497
+
183
498
  function setStatus(message, kind) {
184
- statusEl.textContent = message;
185
- statusEl.className = "status" + (kind ? " " + kind : "");
499
+ statusCopy.textContent = message;
500
+ surface.dataset.state = kind || (busy ? "busy" : "ready");
186
501
  }
187
502
 
188
503
  function setBusy(nextBusy) {
189
504
  busy = nextBusy;
190
- action.disabled = nextBusy;
505
+ action.disabled = nextBusy || completed;
506
+ action.dataset.busy = String(nextBusy);
507
+ surface.setAttribute("aria-busy", String(nextBusy));
508
+ if (nextBusy) {
509
+ surface.dataset.state = "busy";
510
+ actionLabel.textContent = "Waiting for passkey";
511
+ } else {
512
+ if (surface.dataset.state === "busy") {
513
+ surface.dataset.state = "ready";
514
+ }
515
+ actionLabel.textContent = actionDefaultLabel;
516
+ }
517
+ }
518
+
519
+ function showCompletion(title, copy, status, nextStep) {
520
+ completed = true;
521
+ titleEl.textContent = title;
522
+ copyEl.textContent = copy;
523
+ completionCopy.textContent = nextStep;
524
+ completion.hidden = false;
525
+ action.hidden = true;
526
+ helperEl.hidden = true;
527
+ setStatus(status, "success");
528
+ if (completionCallback) {
529
+ window.setTimeout(function () {
530
+ window.location.assign(completionCallback);
531
+ }, 350);
532
+ }
191
533
  }
192
534
 
193
535
  function apiUrl(path) {
@@ -216,6 +558,26 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
216
558
  throw new Error("Passkey link API origin is not trusted.");
217
559
  }
218
560
 
561
+ function resolveCompletionCallback(rawCallback) {
562
+ if (!rawCallback) return null;
563
+ var callback;
564
+ try {
565
+ callback = new URL(rawCallback);
566
+ } catch (_err) {
567
+ throw new Error("Passkey completion callback is invalid.");
568
+ }
569
+ if (
570
+ callback.protocol !== "vex:" ||
571
+ callback.hostname !== "passkey" ||
572
+ callback.pathname !== "/complete" ||
573
+ callback.search ||
574
+ callback.hash
575
+ ) {
576
+ throw new Error("Passkey completion callback is not trusted.");
577
+ }
578
+ return callback.toString();
579
+ }
580
+
219
581
  function isLocalOrigin(url) {
220
582
  return (
221
583
  (url.protocol === "http:" || url.protocol === "https:") &&
@@ -242,23 +604,39 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
242
604
  });
243
605
  var text = await response.text();
244
606
  var payload = null;
245
- if (text.length > 0) {
607
+ var contentType = response.headers.get("content-type") || "";
608
+ if (text.length > 0 && contentType.includes("json")) {
246
609
  try {
247
610
  payload = JSON.parse(text);
248
611
  } catch (_err) {
249
- payload = { error: text };
612
+ payload = null;
250
613
  }
251
614
  }
252
615
  if (!response.ok) {
253
- throw new Error(
254
- payload && payload.error
255
- ? String(payload.error)
256
- : "Request failed with status " + response.status,
257
- );
616
+ throw new Error(apiRequestError(response.status, payload));
258
617
  }
259
618
  return payload;
260
619
  }
261
620
 
621
+ function apiRequestError(status, payload) {
622
+ if (payload && typeof payload.error === "string") {
623
+ return payload.error;
624
+ }
625
+ if (status === 401 || status === 403) {
626
+ return "This passkey request has expired. Return to Vex and start again.";
627
+ }
628
+ if (status === 404) {
629
+ return "This passkey request is no longer available. Return to Vex and start again.";
630
+ }
631
+ if (status === 429) {
632
+ return "Too many attempts. Wait a moment, then try again.";
633
+ }
634
+ if (status >= 500) {
635
+ return "Vex could not complete the passkey request. Try again.";
636
+ }
637
+ return "The passkey request could not be completed. Try again.";
638
+ }
639
+
262
640
  async function run() {
263
641
  if (busy) return;
264
642
  setBusy(true);
@@ -268,18 +646,47 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
268
646
  }
269
647
  if (mode === "register") {
270
648
  await registerPasskey();
649
+ } else if (mode === "register-handoff") {
650
+ await registerPasskeyWithHandoff();
651
+ } else if (mode === "authenticate-handoff") {
652
+ await authenticatePasskeyWithHandoff();
271
653
  } else {
272
654
  await recoverWithPasskey();
273
655
  }
274
656
  } catch (err) {
657
+ actionDefaultLabel =
658
+ mode === "register" || mode === "register-handoff"
659
+ ? "Try again"
660
+ : "Retry passkey";
275
661
  setStatus(errorMessage(err), "error");
276
- action.textContent = mode === "register" ? "Try again" : "Retry passkey";
277
662
  } finally {
278
663
  setBusy(false);
279
664
  }
280
665
  }
281
666
 
282
667
  function errorMessage(err) {
668
+ if (err && err.name === "NotAllowedError") {
669
+ return mode === "register" || mode === "register-handoff"
670
+ ? "Passkey creation was canceled or blocked by this browser. Check the browser prompt, then try again."
671
+ : "Passkey verification was canceled or timed out. Try again when you are ready.";
672
+ }
673
+ if (err && err.name === "InvalidStateError") {
674
+ return "This passkey is already registered with Vex. Try a different passkey.";
675
+ }
676
+ if (err && err.name === "SecurityError") {
677
+ return "This browser cannot use passkeys from the current Vex address.";
678
+ }
679
+ if (err && err.name === "AbortError") {
680
+ return "The passkey request was canceled. Try again when you are ready.";
681
+ }
682
+ if (
683
+ err &&
684
+ (err.name === "NetworkError" ||
685
+ (err.name === "TypeError" &&
686
+ /fetch|network|load failed/i.test(err.message || "")))
687
+ ) {
688
+ return "Vex could not reach the server. Check your connection and try again.";
689
+ }
283
690
  return err instanceof Error ? err.message : String(err);
284
691
  }
285
692
 
@@ -294,15 +701,8 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
294
701
  body: { name: name },
295
702
  token: token,
296
703
  });
297
- var credentialOptions = makeCreationOptions(begin.options);
298
-
299
704
  setStatus("Waiting for browser passkey prompt...");
300
- var credential = await navigator.credentials.create({
301
- publicKey: credentialOptions,
302
- });
303
- if (!credential) {
304
- throw new Error("No passkey was created.");
305
- }
705
+ var credential = await createPasskeyCredential(begin.options);
306
706
 
307
707
  setStatus("Saving passkey...");
308
708
  await apiRequest("/user/" + encodeURIComponent(userID) + "/passkeys/register/finish", {
@@ -313,10 +713,97 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
313
713
  },
314
714
  token: token,
315
715
  });
316
- setStatus("Passkey saved. Return to the Vex CLI.", "success");
317
- titleEl.textContent = "Passkey saved.";
318
- copyEl.textContent = "The CLI can finish connecting now.";
319
- action.textContent = "Done";
716
+ showCompletion(
717
+ "Passkey saved",
718
+ "Your new passkey is ready to use with Vex.",
719
+ "Passkey added to your account.",
720
+ "Return to the Vex CLI. It will finish connecting automatically.",
721
+ );
722
+ }
723
+
724
+ async function registerPasskeyWithHandoff() {
725
+ var token = requiredParam("token");
726
+ var requestID = requiredParam("request");
727
+
728
+ setStatus("Requesting a one-time passkey challenge...");
729
+ var begin = await apiRequest(
730
+ "/auth/passkey/browser-registration/" +
731
+ encodeURIComponent(requestID) +
732
+ "/begin",
733
+ { body: { token: token } },
734
+ );
735
+
736
+ setStatus("Waiting for browser passkey prompt...");
737
+ var credential = await createPasskeyCredential(begin.options);
738
+
739
+ setStatus("Saving passkey...");
740
+ await apiRequest(
741
+ "/auth/passkey/browser-registration/" +
742
+ encodeURIComponent(requestID) +
743
+ "/finish",
744
+ {
745
+ body: {
746
+ response: registrationResponseJSON(credential),
747
+ token: token,
748
+ },
749
+ },
750
+ );
751
+ showCompletion(
752
+ "Passkey saved",
753
+ "Your new passkey is ready to use with Vex.",
754
+ "Passkey added to your account.",
755
+ "You can close this page and return to Vex.",
756
+ );
757
+ }
758
+
759
+ async function createPasskeyCredential(options) {
760
+ var credential = await navigator.credentials.create({
761
+ publicKey: makeCreationOptions(options),
762
+ });
763
+ if (!credential) {
764
+ throw new Error("No passkey was created.");
765
+ }
766
+ return credential;
767
+ }
768
+
769
+ async function authenticatePasskeyWithHandoff() {
770
+ var token = requiredParam("token");
771
+ var requestID = requiredParam("request");
772
+
773
+ setStatus("Requesting a one-time passkey challenge...");
774
+ var begin = await apiRequest(
775
+ "/auth/passkey/browser-authentication/" +
776
+ encodeURIComponent(requestID) +
777
+ "/begin",
778
+ { body: { token: token } },
779
+ );
780
+
781
+ setStatus("Waiting for browser passkey prompt...");
782
+ var credential = await navigator.credentials.get({
783
+ publicKey: makeRequestOptions(begin.options),
784
+ });
785
+ if (!credential) {
786
+ throw new Error("No passkey was returned.");
787
+ }
788
+
789
+ setStatus("Returning verification to Vex...");
790
+ await apiRequest(
791
+ "/auth/passkey/browser-authentication/" +
792
+ encodeURIComponent(requestID) +
793
+ "/finish",
794
+ {
795
+ body: {
796
+ response: authenticationResponseJSON(credential),
797
+ token: token,
798
+ },
799
+ },
800
+ );
801
+ showCompletion(
802
+ "You are verified",
803
+ "Vex can finish signing you in on the desktop.",
804
+ "Passkey verified successfully.",
805
+ "You can close this page and return to Vex.",
806
+ );
320
807
  }
321
808
 
322
809
  async function recoverWithPasskey() {
@@ -365,10 +852,12 @@ const CLI_PASSKEY_PAGE = `<!doctype html>
365
852
  token: token,
366
853
  },
367
854
  );
368
- setStatus("CLI device recovered. Return to the Vex CLI.", "success");
369
- titleEl.textContent = "Device signed in.";
370
- copyEl.textContent = "The CLI can finish connecting now.";
371
- action.textContent = "Done";
855
+ showCompletion(
856
+ "Device approved",
857
+ "Your Vex device can finish connecting now.",
858
+ "Device approved with your passkey.",
859
+ "Return to the Vex CLI. It will finish connecting automatically.",
860
+ );
372
861
  }
373
862
 
374
863
  function requiredParam(name) {