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