@wordkey/sdk 1.0.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/dist/index.js ADDED
@@ -0,0 +1,862 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var src_exports = {};
32
+ __export(src_exports, {
33
+ WordKeyButton: () => WordKeyButton
34
+ });
35
+ module.exports = __toCommonJS(src_exports);
36
+
37
+ // src/WordKeyButton.tsx
38
+ var import_react = require("react");
39
+
40
+ // src/types.ts
41
+ var FIELD_LABELS = {
42
+ name: "Full name",
43
+ email: "Email address",
44
+ phone: "Phone number",
45
+ shipping_address: "Shipping address"
46
+ };
47
+ var DEFAULT_API_URL = "https://app.wordkey.app";
48
+
49
+ // src/fields.ts
50
+ var STANDARD = {
51
+ name: { key: "name", label: FIELD_LABELS.name, type: "text", isStandard: true },
52
+ email: { key: "email", label: FIELD_LABELS.email, type: "email", isStandard: true },
53
+ phone: { key: "phone", label: FIELD_LABELS.phone, type: "tel", isStandard: true },
54
+ shipping_address: {
55
+ key: "shipping_address",
56
+ label: FIELD_LABELS.shipping_address,
57
+ type: "address",
58
+ isStandard: true
59
+ }
60
+ };
61
+ function resolveFields(requested) {
62
+ return requested.map((f) => {
63
+ if (typeof f === "string") {
64
+ const base = STANDARD[f];
65
+ if (base) return { ...base, required: false };
66
+ return { key: f, label: f, type: "text", required: false, isStandard: false };
67
+ }
68
+ return {
69
+ key: f.key,
70
+ label: f.label,
71
+ type: f.type,
72
+ required: f.required ?? false,
73
+ options: f.options,
74
+ isStandard: false
75
+ };
76
+ });
77
+ }
78
+
79
+ // src/auth.ts
80
+ var PROTOCOL_SALT = "wordkey.v1.protocol.salt";
81
+ async function hashWord(word) {
82
+ const data = new TextEncoder().encode(word.toLowerCase().trim() + PROTOCOL_SALT);
83
+ const digest = await crypto.subtle.digest("SHA-256", data);
84
+ return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
85
+ }
86
+ function generateHolderSecret() {
87
+ const bytes = new Uint8Array(32);
88
+ crypto.getRandomValues(bytes);
89
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
90
+ }
91
+ var DEVICE_STORE = "wordkey.device_id";
92
+ function getDeviceId() {
93
+ if (typeof window === "undefined") return "server";
94
+ try {
95
+ const existing = localStorage.getItem(DEVICE_STORE);
96
+ if (existing) return existing;
97
+ const bytes = new Uint8Array(16);
98
+ crypto.getRandomValues(bytes);
99
+ const id = Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
100
+ localStorage.setItem(DEVICE_STORE, id);
101
+ return id;
102
+ } catch {
103
+ return "ephemeral";
104
+ }
105
+ }
106
+ var DeviceApprovalRequiredError = class extends Error {
107
+ code = "device_approval_required";
108
+ constructor(message) {
109
+ super(message);
110
+ this.name = "DeviceApprovalRequiredError";
111
+ }
112
+ };
113
+ async function verifyWord(apiUrl = DEFAULT_API_URL, publishableKey, word) {
114
+ const wordHash = await hashWord(word);
115
+ const res = await fetch(`${apiUrl}/api/auth/verify`, {
116
+ method: "POST",
117
+ headers: { "Content-Type": "application/json" },
118
+ body: JSON.stringify({ wordHash, deviceId: getDeviceId(), publishableKey })
119
+ });
120
+ const json = await res.json();
121
+ if (!res.ok) {
122
+ if (json?.error?.code === "device_approval_required") {
123
+ throw new DeviceApprovalRequiredError(
124
+ json?.error?.message ?? "Approve this sign-in from your phone."
125
+ );
126
+ }
127
+ throw new Error(json?.error?.message ?? "Authentication failed");
128
+ }
129
+ return json;
130
+ }
131
+
132
+ // src/profile-popup.ts
133
+ var POPUP_TIMEOUT_MS = 5 * 60 * 1e3;
134
+ var PENDING_STORE = "wordkey.pending_share";
135
+ var RESULT_HASH = "#wordkey_result=";
136
+ function b64urlEncode(bytes) {
137
+ let bin = "";
138
+ for (const b of bytes) bin += String.fromCharCode(b);
139
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
140
+ }
141
+ function b64urlDecode(s) {
142
+ const b64 = s.replace(/-/g, "+").replace(/_/g, "/");
143
+ const bin = atob(b64 + "=".repeat((4 - b64.length % 4) % 4));
144
+ const out = new Uint8Array(bin.length);
145
+ for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
146
+ return out;
147
+ }
148
+ function encodeJson(value) {
149
+ return b64urlEncode(new TextEncoder().encode(JSON.stringify(value)));
150
+ }
151
+ function decodeJson(s) {
152
+ try {
153
+ return JSON.parse(new TextDecoder().decode(b64urlDecode(s)));
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+ function newRequestId() {
159
+ const bytes = new Uint8Array(16);
160
+ crypto.getRandomValues(bytes);
161
+ return b64urlEncode(bytes);
162
+ }
163
+ function openConsentWindow(apiUrl) {
164
+ const w = 440;
165
+ const h = 660;
166
+ const left = Math.max(0, (window.screenX ?? 0) + (window.outerWidth - w) / 2);
167
+ const top = Math.max(0, (window.screenY ?? 0) + (window.outerHeight - h) / 3);
168
+ try {
169
+ return window.open(
170
+ `${apiUrl}/authorize`,
171
+ "wordkey-authorize",
172
+ `popup=yes,width=${w},height=${h},left=${left},top=${top}`
173
+ );
174
+ } catch {
175
+ return null;
176
+ }
177
+ }
178
+ function runPopupFlow(input2) {
179
+ const { popup, apiOrigin, requestId, signal } = input2;
180
+ return new Promise((resolve) => {
181
+ let settled = false;
182
+ const cleanup = () => {
183
+ window.removeEventListener("message", onMsg);
184
+ clearInterval(closedPoll);
185
+ clearTimeout(timer);
186
+ signal?.removeEventListener("abort", onAbort);
187
+ };
188
+ const finish = (r) => {
189
+ if (settled) return;
190
+ settled = true;
191
+ cleanup();
192
+ resolve(r);
193
+ };
194
+ const onAbort = () => {
195
+ try {
196
+ popup.close();
197
+ } catch {
198
+ }
199
+ finish({ granted: false, values: {}, cancelled: true });
200
+ };
201
+ const onMsg = (e) => {
202
+ if (e.origin !== apiOrigin || e.source !== popup) return;
203
+ const data = e.data;
204
+ if (!data || typeof data !== "object") return;
205
+ if (data.type === "wordkey:authorize:ready") {
206
+ popup.postMessage(
207
+ {
208
+ type: "wordkey:authorize:init",
209
+ requestId,
210
+ publishableKey: input2.publishableKey,
211
+ ticket: input2.ticket,
212
+ fields: input2.fields
213
+ },
214
+ apiOrigin
215
+ );
216
+ }
217
+ if (data.type === "wordkey:profile:result" && data.requestId === requestId) {
218
+ finish({
219
+ granted: data.granted === true,
220
+ values: data.values ?? {}
221
+ });
222
+ }
223
+ };
224
+ window.addEventListener("message", onMsg);
225
+ const closedPoll = setInterval(() => {
226
+ if (popup.closed) finish({ granted: false, values: {}, closed: true });
227
+ }, 400);
228
+ const timer = setTimeout(
229
+ () => finish({ granted: false, values: {} }),
230
+ POPUP_TIMEOUT_MS
231
+ );
232
+ if (signal) {
233
+ if (signal.aborted) onAbort();
234
+ else signal.addEventListener("abort", onAbort);
235
+ }
236
+ });
237
+ }
238
+ async function beginRedirectShare(input2) {
239
+ const pair = await crypto.subtle.generateKey(
240
+ { name: "ECDH", namedCurve: "P-256" },
241
+ true,
242
+ ["deriveKey"]
243
+ );
244
+ const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
245
+ const privateKeyJwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
246
+ const pending = {
247
+ requestId: input2.requestId,
248
+ userId: input2.userId,
249
+ isNewUser: input2.isNewUser,
250
+ fields: input2.fields,
251
+ privateKeyJwk
252
+ };
253
+ sessionStorage.setItem(PENDING_STORE, JSON.stringify(pending));
254
+ const returnUrl = window.location.href.split("#")[0];
255
+ const request = {
256
+ v: 1,
257
+ requestId: input2.requestId,
258
+ publishableKey: input2.publishableKey,
259
+ ticket: input2.ticket,
260
+ fields: input2.fields,
261
+ origin: window.location.origin,
262
+ returnUrl,
263
+ sdkPub: publicJwk
264
+ };
265
+ window.location.assign(`${input2.apiUrl}/authorize#wkreq=${encodeJson(request)}`);
266
+ }
267
+ async function consumePendingRedirect() {
268
+ if (typeof window === "undefined") return null;
269
+ const hash = window.location.hash;
270
+ if (!hash.startsWith(RESULT_HASH)) return null;
271
+ const stored = sessionStorage.getItem(PENDING_STORE);
272
+ const envelope = decodeJson(
273
+ hash.slice(RESULT_HASH.length)
274
+ );
275
+ history.replaceState(null, "", window.location.pathname + window.location.search);
276
+ if (!stored || !envelope) return null;
277
+ let pending;
278
+ try {
279
+ pending = JSON.parse(stored);
280
+ } catch {
281
+ return null;
282
+ }
283
+ if (envelope.requestId !== pending.requestId) return null;
284
+ sessionStorage.removeItem(PENDING_STORE);
285
+ if (envelope.granted !== true) {
286
+ return { pending, result: { granted: false, values: {} } };
287
+ }
288
+ if (!envelope.epk || !envelope.iv || !envelope.ct) return null;
289
+ try {
290
+ const privateKey = await crypto.subtle.importKey(
291
+ "jwk",
292
+ pending.privateKeyJwk,
293
+ { name: "ECDH", namedCurve: "P-256" },
294
+ false,
295
+ ["deriveKey"]
296
+ );
297
+ const peer = await crypto.subtle.importKey(
298
+ "jwk",
299
+ envelope.epk,
300
+ { name: "ECDH", namedCurve: "P-256" },
301
+ false,
302
+ []
303
+ );
304
+ const key = await crypto.subtle.deriveKey(
305
+ { name: "ECDH", public: peer },
306
+ privateKey,
307
+ { name: "AES-GCM", length: 256 },
308
+ false,
309
+ ["decrypt"]
310
+ );
311
+ const plain = await crypto.subtle.decrypt(
312
+ { name: "AES-GCM", iv: b64urlDecode(envelope.iv) },
313
+ key,
314
+ b64urlDecode(envelope.ct)
315
+ );
316
+ const parsed = JSON.parse(new TextDecoder().decode(plain));
317
+ return {
318
+ pending,
319
+ result: { granted: true, values: parsed.values ?? {} }
320
+ };
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+
326
+ // src/fonts.ts
327
+ var loaded = false;
328
+ function ensureFont() {
329
+ if (loaded || typeof document === "undefined") return;
330
+ loaded = true;
331
+ const href = "https://fonts.googleapis.com/css2?family=Cormorant+Garamond:wght@600&display=swap";
332
+ const link = document.createElement("link");
333
+ link.rel = "stylesheet";
334
+ link.href = href;
335
+ link.media = "print";
336
+ link.onload = () => {
337
+ link.media = "all";
338
+ };
339
+ document.head.appendChild(link);
340
+ }
341
+
342
+ // src/theme.ts
343
+ var C = {
344
+ navy: "#0B1529",
345
+ navy2: "#111E38",
346
+ navy3: "#1A2B50",
347
+ purple: "#7C3AED",
348
+ purpleL: "#A78BFA",
349
+ purpleD: "#5B21B6",
350
+ cream: "#F4F4F0",
351
+ muted: "#7A8BAD",
352
+ teal: "#0D9488",
353
+ red: "#EF4444",
354
+ border: "rgba(124,58,237,0.2)"
355
+ };
356
+ var FONT_DISPLAY = "'Cormorant Garamond', Georgia, 'Times New Roman', serif";
357
+ var FONT_BODY = "'DM Sans', system-ui, -apple-system, Segoe UI, Roboto, sans-serif";
358
+ var overlay = {
359
+ position: "fixed",
360
+ inset: 0,
361
+ zIndex: 2147483e3,
362
+ display: "flex",
363
+ alignItems: "center",
364
+ justifyContent: "center",
365
+ background: "rgba(0,0,0,0.7)",
366
+ backdropFilter: "blur(4px)",
367
+ padding: 16,
368
+ fontFamily: FONT_BODY
369
+ };
370
+ var panel = {
371
+ width: "100%",
372
+ maxWidth: 420,
373
+ background: C.navy2,
374
+ border: `1px solid ${C.border}`,
375
+ borderRadius: 20,
376
+ boxShadow: "0 20px 60px rgba(0,0,0,0.5)",
377
+ padding: 28,
378
+ color: C.cream,
379
+ boxSizing: "border-box",
380
+ maxHeight: "90vh",
381
+ overflowY: "auto"
382
+ };
383
+ var input = {
384
+ width: "100%",
385
+ height: 46,
386
+ borderRadius: 12,
387
+ border: `1px solid ${C.border}`,
388
+ background: C.navy,
389
+ color: C.cream,
390
+ padding: "0 14px",
391
+ fontSize: 15,
392
+ fontFamily: FONT_BODY,
393
+ boxSizing: "border-box",
394
+ outline: "none"
395
+ };
396
+ function primaryBtn(disabled) {
397
+ return {
398
+ flex: 1,
399
+ height: 46,
400
+ borderRadius: 12,
401
+ border: "none",
402
+ background: disabled ? "#3a2d63" : C.purple,
403
+ color: C.cream,
404
+ fontSize: 15,
405
+ fontWeight: 600,
406
+ cursor: disabled ? "not-allowed" : "pointer",
407
+ fontFamily: FONT_BODY
408
+ };
409
+ }
410
+ var ghostBtn = {
411
+ flex: 1,
412
+ height: 46,
413
+ borderRadius: 12,
414
+ border: `1px solid ${C.border}`,
415
+ background: "transparent",
416
+ color: C.cream,
417
+ fontSize: 15,
418
+ cursor: "pointer",
419
+ fontFamily: FONT_BODY
420
+ };
421
+
422
+ // src/WordKeyButton.tsx
423
+ var import_jsx_runtime = require("react/jsx-runtime");
424
+ var sizePx = { sm: 20, md: 28, lg: 38 };
425
+ function WordKeyButton({
426
+ publishableKey,
427
+ onSuccess,
428
+ onError,
429
+ size = "md",
430
+ label = "Word?",
431
+ requestedFields,
432
+ apiUrl = DEFAULT_API_URL
433
+ }) {
434
+ const [open, setOpen] = (0, import_react.useState)(false);
435
+ (0, import_react.useEffect)(() => {
436
+ ensureFont();
437
+ }, []);
438
+ const resumed = (0, import_react.useRef)(false);
439
+ (0, import_react.useEffect)(() => {
440
+ if (resumed.current) return;
441
+ resumed.current = true;
442
+ void consumePendingRedirect().then((r) => {
443
+ if (!r) return;
444
+ const profile = buildProfile(
445
+ r.pending.fields,
446
+ r.result.granted ? r.result.values : {}
447
+ );
448
+ onSuccess({
449
+ id: r.pending.userId,
450
+ isNewUser: r.pending.isNewUser,
451
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
452
+ profile
453
+ });
454
+ });
455
+ }, []);
456
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
457
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
458
+ "button",
459
+ {
460
+ onClick: () => setOpen(true),
461
+ style: {
462
+ background: "transparent",
463
+ border: "none",
464
+ padding: 0,
465
+ cursor: "pointer",
466
+ fontFamily: FONT_DISPLAY,
467
+ fontWeight: 600,
468
+ fontSize: sizePx[size],
469
+ lineHeight: 1
470
+ },
471
+ "aria-label": label,
472
+ children: [
473
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purple }, children: "Word" }),
474
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purpleL }, children: "?" })
475
+ ]
476
+ }
477
+ ),
478
+ open && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
479
+ AuthModal,
480
+ {
481
+ publishableKey,
482
+ apiUrl,
483
+ requestedFields,
484
+ onClose: () => setOpen(false),
485
+ onSuccess: (u) => {
486
+ setOpen(false);
487
+ onSuccess(u);
488
+ },
489
+ onError: (e) => onError?.(e)
490
+ }
491
+ )
492
+ ] });
493
+ }
494
+ function AuthModal({
495
+ publishableKey,
496
+ apiUrl,
497
+ requestedFields,
498
+ onClose,
499
+ onSuccess,
500
+ onError
501
+ }) {
502
+ const fields = (0, import_react.useMemo)(
503
+ () => requestedFields ? resolveFields(requestedFields) : [],
504
+ [requestedFields]
505
+ );
506
+ const apiOrigin = (0, import_react.useMemo)(() => {
507
+ try {
508
+ return new URL(apiUrl).origin;
509
+ } catch {
510
+ return apiUrl;
511
+ }
512
+ }, [apiUrl]);
513
+ const [step, setStep] = (0, import_react.useState)("word");
514
+ const [word, setWord] = (0, import_react.useState)("");
515
+ const [error, setError] = (0, import_react.useState)(null);
516
+ const abortRef = (0, import_react.useRef)(null);
517
+ if (!abortRef.current) abortRef.current = new AbortController();
518
+ (0, import_react.useEffect)(() => () => abortRef.current?.abort(), []);
519
+ const [approvalQr, setApprovalQr] = (0, import_react.useState)(null);
520
+ const [approvalUrl, setApprovalUrl] = (0, import_react.useState)(null);
521
+ const pendingApproval = (0, import_react.useRef)(null);
522
+ async function authenticate() {
523
+ setError(null);
524
+ setStep("authenticating");
525
+ try {
526
+ const result = await verifyWord(apiUrl, publishableKey, word);
527
+ await completeAuth(result.userId, result.isNewUser, result.profileTicket);
528
+ } catch (e) {
529
+ if (e instanceof DeviceApprovalRequiredError) {
530
+ await startDeviceApproval();
531
+ return;
532
+ }
533
+ const message = e.message;
534
+ setError(message);
535
+ setStep("error");
536
+ onError({ code: "auth_failed", message });
537
+ }
538
+ }
539
+ async function completeAuth(userId, isNewUser, profileTicket) {
540
+ const finish = (profile) => onSuccess({
541
+ id: userId,
542
+ isNewUser,
543
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
544
+ profile
545
+ });
546
+ if (fields.length === 0) {
547
+ finish();
548
+ return;
549
+ }
550
+ const requestId = newRequestId();
551
+ const popup = openConsentWindow(apiUrl);
552
+ if (!popup) {
553
+ await beginRedirectShare({
554
+ apiUrl,
555
+ requestId,
556
+ publishableKey,
557
+ ticket: profileTicket,
558
+ fields,
559
+ userId,
560
+ isNewUser
561
+ });
562
+ return;
563
+ }
564
+ setStep("sharing");
565
+ const result = await runPopupFlow({
566
+ popup,
567
+ apiOrigin,
568
+ requestId,
569
+ publishableKey,
570
+ ticket: profileTicket,
571
+ fields,
572
+ signal: abortRef.current?.signal
573
+ });
574
+ if (result.cancelled) return;
575
+ finish(buildProfile(fields, result.granted ? result.values : {}));
576
+ }
577
+ async function startDeviceApproval() {
578
+ setStep("device-approval");
579
+ setError(null);
580
+ try {
581
+ const holderSecret = generateHolderSecret();
582
+ const res = await fetch(`${apiUrl}/api/auth/device-approval/create`, {
583
+ method: "POST",
584
+ headers: { "Content-Type": "application/json" },
585
+ body: JSON.stringify({
586
+ publishableKey,
587
+ deviceId: getDeviceId(),
588
+ holderSecret
589
+ })
590
+ });
591
+ const data = await res.json();
592
+ if (!res.ok) {
593
+ throw new Error(data?.error?.message ?? "Could not start phone approval");
594
+ }
595
+ setApprovalUrl(data.approveUrl);
596
+ try {
597
+ const QRCode = await import("qrcode");
598
+ setApprovalQr(
599
+ await QRCode.toDataURL(data.approveUrl, {
600
+ margin: 1,
601
+ color: { dark: "#A78BFA", light: "#0B1529" }
602
+ })
603
+ );
604
+ } catch {
605
+ }
606
+ void pollDeviceApproval(data.sessionKey, holderSecret);
607
+ } catch (e) {
608
+ const message = e.message;
609
+ setError(message);
610
+ setStep("error");
611
+ onError({ code: "device_approval_failed", message });
612
+ }
613
+ }
614
+ async function pollDeviceApproval(sessionKey, holderSecret) {
615
+ for (let i = 0; i < 80; i++) {
616
+ await new Promise((r) => setTimeout(r, 1500));
617
+ let data;
618
+ try {
619
+ const res = await fetch(
620
+ `${apiUrl}/api/auth/device-approval/status/${sessionKey}`,
621
+ { headers: { "X-WordKey-Approval-Secret": holderSecret } }
622
+ );
623
+ data = await res.json();
624
+ } catch {
625
+ continue;
626
+ }
627
+ if (data.status === "approved" && data.token) {
628
+ const claims = decodeToken(data.token);
629
+ const userId = claims?.userId ?? "";
630
+ const isNewUser = claims?.isNewUser ?? false;
631
+ if (fields.length === 0) {
632
+ await completeAuth(userId, isNewUser, data.profileTicket);
633
+ } else {
634
+ pendingApproval.current = {
635
+ userId,
636
+ isNewUser,
637
+ profileTicket: data.profileTicket
638
+ };
639
+ setStep("approved");
640
+ }
641
+ return;
642
+ }
643
+ if (data.status === "expired") {
644
+ setError("The approval request expired. Try again.");
645
+ setStep("error");
646
+ onError({
647
+ code: "device_approval_expired",
648
+ message: "Approval request expired"
649
+ });
650
+ return;
651
+ }
652
+ }
653
+ setError("Approval timed out. Try again.");
654
+ setStep("error");
655
+ onError({ code: "device_approval_timeout", message: "Approval timed out" });
656
+ }
657
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: overlay, role: "dialog", "aria-modal": "true", onClick: onClose, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: panel, onClick: (e) => e.stopPropagation(), children: [
658
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Header, {}),
659
+ step === "word" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
660
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted, marginTop: 0, fontSize: 14 }, children: "Enter your word to continue." }),
661
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
662
+ WordInput,
663
+ {
664
+ autoFocus: true,
665
+ value: word,
666
+ onChange: (e) => setWord(e.target.value),
667
+ onKeyDown: (e) => {
668
+ if (e.key === "Enter" && word.trim().length >= 3)
669
+ void authenticate();
670
+ },
671
+ placeholder: "Your word",
672
+ style: { ...input, textTransform: "lowercase" }
673
+ }
674
+ ),
675
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorText, { children: error }),
676
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Row, { children: [
677
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Cancel" }),
678
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
679
+ "button",
680
+ {
681
+ style: primaryBtn(word.trim().length < 3),
682
+ disabled: word.trim().length < 3,
683
+ onClick: () => void authenticate(),
684
+ children: "Continue"
685
+ }
686
+ )
687
+ ] }),
688
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Footer, {})
689
+ ] }),
690
+ step === "authenticating" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center", padding: "24px 0" }, children: [
691
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, {}),
692
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted }, children: "Verifying\u2026" })
693
+ ] }),
694
+ step === "sharing" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center", padding: "12px 0" }, children: [
695
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, {}),
696
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { style: { fontFamily: FONT_DISPLAY, fontSize: 22, margin: "4px 0" }, children: "Finish in the WordKey window" }),
697
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted, fontSize: 13 }, children: "Review what this site will receive in the window that just opened. Closing it signs you in without sharing your details." })
698
+ ] }),
699
+ step === "device-approval" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center" }, children: [
700
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { style: { fontFamily: FONT_DISPLAY, fontSize: 24, margin: "4px 0" }, children: "Approve on your phone" }),
701
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted, fontSize: 14, marginTop: 4 }, children: "This device isn't recognized. Scan with the phone where your word lives, then approve." }),
702
+ approvalQr ? (
703
+ // eslint-disable-next-line @next/next/no-img-element
704
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
705
+ "img",
706
+ {
707
+ src: approvalQr,
708
+ alt: "Scan to approve on your phone",
709
+ width: 180,
710
+ height: 180,
711
+ style: { margin: "16px auto", display: "block", borderRadius: 12 }
712
+ }
713
+ )
714
+ ) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { padding: "24px 0" }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, {}) }),
715
+ approvalUrl && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
716
+ "a",
717
+ {
718
+ href: approvalUrl,
719
+ target: "_blank",
720
+ rel: "noopener noreferrer",
721
+ style: { color: C.purpleL, fontSize: 12, wordBreak: "break-all" },
722
+ children: "Or open the approval link"
723
+ }
724
+ ),
725
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted, fontSize: 12, marginTop: 12 }, children: "Waiting for approval\u2026" }),
726
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorText, { children: error }),
727
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Cancel" }) })
728
+ ] }),
729
+ step === "approved" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center", padding: "12px 0" }, children: [
730
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { fontSize: 34, color: C.teal }, children: "\u2713" }),
731
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { style: { fontFamily: FONT_DISPLAY, fontSize: 24, margin: "8px 0 4px" }, children: "Approved on your phone" }),
732
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted, fontSize: 14 }, children: "Continue to review what this site will receive." }),
733
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Row, { children: [
734
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Cancel" }),
735
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
736
+ "button",
737
+ {
738
+ style: primaryBtn(false),
739
+ onClick: () => {
740
+ const p = pendingApproval.current;
741
+ if (p) void completeAuth(p.userId, p.isNewUser, p.profileTicket);
742
+ },
743
+ children: "Continue"
744
+ }
745
+ )
746
+ ] })
747
+ ] }),
748
+ step === "error" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
749
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorText, { children: error }),
750
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Row, { children: [
751
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Close" }),
752
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
753
+ "button",
754
+ {
755
+ style: primaryBtn(false),
756
+ onClick: () => {
757
+ setError(null);
758
+ setStep("word");
759
+ },
760
+ children: "Try again"
761
+ }
762
+ )
763
+ ] })
764
+ ] })
765
+ ] }) });
766
+ }
767
+ function WordInput({
768
+ style,
769
+ ...props
770
+ }) {
771
+ const [masked, setMasked] = (0, import_react.useState)(false);
772
+ (0, import_react.useEffect)(() => {
773
+ try {
774
+ if (typeof CSS !== "undefined" && CSS.supports("-webkit-text-security", "disc")) {
775
+ setMasked(true);
776
+ }
777
+ } catch {
778
+ }
779
+ }, []);
780
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
781
+ "input",
782
+ {
783
+ type: masked ? "text" : "password",
784
+ name: "word",
785
+ autoComplete: "off",
786
+ autoCapitalize: "none",
787
+ autoCorrect: "off",
788
+ spellCheck: false,
789
+ inputMode: "text",
790
+ style: masked ? { ...style, WebkitTextSecurity: "disc" } : style,
791
+ ...props
792
+ }
793
+ );
794
+ }
795
+ function valuePresent(v) {
796
+ if (v === void 0) return false;
797
+ if (typeof v === "string") return v.trim().length > 0;
798
+ return Object.values(v).some((x) => x && String(x).trim().length > 0);
799
+ }
800
+ function decodeToken(token) {
801
+ try {
802
+ const part = token.split(".")[1];
803
+ if (!part) return null;
804
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
805
+ const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
806
+ return JSON.parse(json);
807
+ } catch {
808
+ return null;
809
+ }
810
+ }
811
+ function buildProfile(fields, granted) {
812
+ const profile = {};
813
+ const custom = {};
814
+ for (const f of fields) {
815
+ const v = granted[f.key];
816
+ if (!valuePresent(v)) continue;
817
+ if (f.key === "name" && typeof v === "string") profile.name = v;
818
+ else if (f.key === "email" && typeof v === "string") profile.email = v;
819
+ else if (f.key === "phone" && typeof v === "string") profile.phone = v;
820
+ else if (f.key === "shipping_address" && typeof v === "object")
821
+ profile.shippingAddress = v;
822
+ else if (typeof v === "string") custom[f.key] = v;
823
+ }
824
+ if (Object.keys(custom).length) profile.custom = custom;
825
+ return profile;
826
+ }
827
+ function Header() {
828
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { marginBottom: 14 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: { fontFamily: FONT_DISPLAY, fontSize: 28, fontWeight: 600 }, children: [
829
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purple }, children: "Word" }),
830
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purpleL }, children: "?" })
831
+ ] }) });
832
+ }
833
+ function Footer() {
834
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { marginTop: 16, fontSize: 11, color: C.muted, textAlign: "center" }, children: "Secured by WordKey \xB7 one word, every door" });
835
+ }
836
+ function Row({ children }) {
837
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { display: "flex", gap: 10, marginTop: 20 }, children });
838
+ }
839
+ function ErrorText({ children }) {
840
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.red, fontSize: 13, marginTop: 12 }, children });
841
+ }
842
+ function Spinner() {
843
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
844
+ "div",
845
+ {
846
+ style: {
847
+ width: 32,
848
+ height: 32,
849
+ margin: "0 auto 12px",
850
+ border: `3px solid ${C.border}`,
851
+ borderTopColor: C.purpleL,
852
+ borderRadius: "50%",
853
+ animation: "wk-spin 0.8s linear infinite"
854
+ },
855
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("style", { children: "@keyframes wk-spin{to{transform:rotate(360deg)}}" })
856
+ }
857
+ );
858
+ }
859
+ // Annotate the CommonJS export names for ESM import in node:
860
+ 0 && (module.exports = {
861
+ WordKeyButton
862
+ });