@wordkey/sdk 1.0.1 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,25 +10,77 @@ npm install @wordkey/sdk
10
10
 
11
11
  ## 1. Add the button
12
12
 
13
+ Signup and login are different user moments, so the SDK gives you both:
14
+
15
+ - **`mode="signup"`** — *Get your word*: a brand-new user gets a word in
16
+ WordKey's onboarding window, this device is enrolled, and they come back
17
+ signed in to your site — one flow.
18
+ - **`mode="login"`** (default) — *Word?*: a returning user types the word
19
+ they already have.
20
+
21
+ Both deliver the identical `onSuccess` user, so your backend handles one
22
+ contract.
23
+
13
24
  ```jsx
14
25
  import { WordKeyButton } from "@wordkey/sdk";
15
26
 
27
+ const handleUser = async (user) => {
28
+ // user.token is a single-use proof of authentication. Hand it to
29
+ // YOUR backend and verify it there (step 2) before creating a
30
+ // session — never trust user.id straight from the browser.
31
+ await fetch("/api/wordkey-login", {
32
+ method: "POST",
33
+ headers: { "Content-Type": "application/json" },
34
+ body: JSON.stringify({ token: user.token }),
35
+ });
36
+ };
37
+
16
38
  export function LoginPage() {
17
39
  return (
18
- <WordKeyButton
19
- publishableKey="wk_pub_xxxxxxxxxxxx"
20
- onSuccess={async (user) => {
21
- // user.token is a single-use proof of authentication. Hand it to
22
- // YOUR backend and verify it there (step 2) before creating a
23
- // session — never trust user.id straight from the browser.
24
- await fetch("/api/wordkey-login", {
25
- method: "POST",
26
- headers: { "Content-Type": "application/json" },
27
- body: JSON.stringify({ token: user.token }),
28
- });
29
- }}
30
- onError={(err) => console.error(err)}
31
- />
40
+ <>
41
+ <WordKeyButton
42
+ publishableKey="wk_pub_xxxxxxxxxxxx"
43
+ mode="signup"
44
+ onSuccess={handleUser}
45
+ />
46
+ <WordKeyButton
47
+ publishableKey="wk_pub_xxxxxxxxxxxx"
48
+ onSuccess={handleUser}
49
+ onError={(err) => console.error(err)}
50
+ />
51
+ </>
52
+ );
53
+ }
54
+ ```
55
+
56
+ ### Use your own buttons (WordKeyModal)
57
+
58
+ `WordKeyButton` is sugar over the controlled `WordKeyModal`. Render the modal
59
+ once, unconditionally, and open either flow from any UI you like:
60
+
61
+ ```jsx
62
+ import { WordKeyModal } from "@wordkey/sdk";
63
+ import { useState } from "react";
64
+
65
+ export function AuthButtons() {
66
+ const [flow, setFlow] = useState(null); // null | "signup" | "login"
67
+ return (
68
+ <>
69
+ <button className="cta" onClick={() => setFlow("signup")}>
70
+ Get Your Word
71
+ </button>
72
+ <a onClick={() => setFlow("login")}>Already have a word?</a>
73
+
74
+ {/* Always mounted — toggle `open`, don't conditionally render: the
75
+ modal also resumes the popup-blocked redirect fallback on mount. */}
76
+ <WordKeyModal
77
+ publishableKey="wk_pub_xxxxxxxxxxxx"
78
+ open={flow !== null}
79
+ mode={flow ?? "login"}
80
+ onClose={() => setFlow(null)}
81
+ onSuccess={handleUser}
82
+ />
83
+ </>
32
84
  );
33
85
  }
34
86
  ```
package/dist/index.d.mts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
- import { W as WordKeyUser, a as WordKeyError, R as RequestedField } from './types-B4z3Zton.mjs';
3
- export { A as Address, b as AuthEvent, C as CustomField, P as ProfileField, c as WordKeyProfile } from './types-B4z3Zton.mjs';
2
+ import { W as WordKeyUser, a as WordKeyError, b as WordKeyMode, R as RequestedField } from './types-WFjkfsy6.mjs';
3
+ export { A as Address, c as AuthEvent, C as CustomField, P as ProfileField, d as WordKeyProfile } from './types-WFjkfsy6.mjs';
4
4
 
5
5
  interface WordKeyButtonProps {
6
6
  publishableKey: string;
@@ -9,11 +9,48 @@ interface WordKeyButtonProps {
9
9
  theme?: "dark" | "light" | "auto";
10
10
  size?: "sm" | "md" | "lg";
11
11
  label?: string;
12
+ /**
13
+ * Which flow this button opens. "login" (default, the 1.0.x behavior)
14
+ * renders the Word? mark for returning users; "signup" renders
15
+ * "Get your word" and opens WordKey onboarding for brand-new users.
16
+ * Render one button of each mode for the two user moments — or drive
17
+ * WordKeyModal yourself from any UI you like.
18
+ */
19
+ mode?: WordKeyMode;
12
20
  /** Profile fields to request. Standard names or custom field descriptors. */
13
21
  requestedFields?: RequestedField[];
14
22
  /** Override the protocol host (defaults to app.wordkey.app). */
15
23
  apiUrl?: string;
16
24
  }
17
- declare function WordKeyButton({ publishableKey, onSuccess, onError, size, label, requestedFields, apiUrl, }: WordKeyButtonProps): react.JSX.Element;
25
+ declare function WordKeyButton({ publishableKey, onSuccess, onError, size, label, mode, requestedFields, apiUrl, }: WordKeyButtonProps): react.JSX.Element;
18
26
 
19
- export { RequestedField, WordKeyButton, type WordKeyButtonProps, WordKeyError, WordKeyUser };
27
+ /**
28
+ * The controlled WordKey flow. Render it ONCE, unconditionally (toggle `open`,
29
+ * don't conditionally mount it): the redirect-fallback return leg is consumed
30
+ * in this component's mount effect, and a conditionally rendered modal would
31
+ * miss it on the page load that carries the result fragment.
32
+ *
33
+ * mode "login" — the Word? flow for returning users (the 1.0.x behavior).
34
+ * mode "signup" — the Get-your-word flow: opens WordKey's first-party
35
+ * onboarding page in a popup, which assigns a word, enrolls this device, and
36
+ * hands back the same single-use token login yields — so `onSuccess` has ONE
37
+ * contract across both modes and every WordKeyButton stays a thin wrapper.
38
+ */
39
+ interface WordKeyModalProps {
40
+ publishableKey: string;
41
+ /** Controlled visibility. The modal calls `onClose` when it dismisses itself
42
+ * (cancel, overlay click, or right before delivering `onSuccess`). */
43
+ open: boolean;
44
+ onClose: () => void;
45
+ onSuccess: (user: WordKeyUser) => void;
46
+ /** Which flow to open. Defaults to "login". */
47
+ mode?: WordKeyMode;
48
+ onError?: (error: WordKeyError) => void;
49
+ /** Profile fields to request. Standard names or custom field descriptors. */
50
+ requestedFields?: RequestedField[];
51
+ /** Override the protocol host (defaults to app.wordkey.app). */
52
+ apiUrl?: string;
53
+ }
54
+ declare function WordKeyModal({ publishableKey, open, onClose, onSuccess, mode, onError, requestedFields, apiUrl, }: WordKeyModalProps): react.JSX.Element | null;
55
+
56
+ export { RequestedField, WordKeyButton, type WordKeyButtonProps, WordKeyError, WordKeyModal, type WordKeyModalProps, WordKeyMode, WordKeyUser };
package/dist/index.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
- import { W as WordKeyUser, a as WordKeyError, R as RequestedField } from './types-B4z3Zton.js';
3
- export { A as Address, b as AuthEvent, C as CustomField, P as ProfileField, c as WordKeyProfile } from './types-B4z3Zton.js';
2
+ import { W as WordKeyUser, a as WordKeyError, b as WordKeyMode, R as RequestedField } from './types-WFjkfsy6.js';
3
+ export { A as Address, c as AuthEvent, C as CustomField, P as ProfileField, d as WordKeyProfile } from './types-WFjkfsy6.js';
4
4
 
5
5
  interface WordKeyButtonProps {
6
6
  publishableKey: string;
@@ -9,11 +9,48 @@ interface WordKeyButtonProps {
9
9
  theme?: "dark" | "light" | "auto";
10
10
  size?: "sm" | "md" | "lg";
11
11
  label?: string;
12
+ /**
13
+ * Which flow this button opens. "login" (default, the 1.0.x behavior)
14
+ * renders the Word? mark for returning users; "signup" renders
15
+ * "Get your word" and opens WordKey onboarding for brand-new users.
16
+ * Render one button of each mode for the two user moments — or drive
17
+ * WordKeyModal yourself from any UI you like.
18
+ */
19
+ mode?: WordKeyMode;
12
20
  /** Profile fields to request. Standard names or custom field descriptors. */
13
21
  requestedFields?: RequestedField[];
14
22
  /** Override the protocol host (defaults to app.wordkey.app). */
15
23
  apiUrl?: string;
16
24
  }
17
- declare function WordKeyButton({ publishableKey, onSuccess, onError, size, label, requestedFields, apiUrl, }: WordKeyButtonProps): react.JSX.Element;
25
+ declare function WordKeyButton({ publishableKey, onSuccess, onError, size, label, mode, requestedFields, apiUrl, }: WordKeyButtonProps): react.JSX.Element;
18
26
 
19
- export { RequestedField, WordKeyButton, type WordKeyButtonProps, WordKeyError, WordKeyUser };
27
+ /**
28
+ * The controlled WordKey flow. Render it ONCE, unconditionally (toggle `open`,
29
+ * don't conditionally mount it): the redirect-fallback return leg is consumed
30
+ * in this component's mount effect, and a conditionally rendered modal would
31
+ * miss it on the page load that carries the result fragment.
32
+ *
33
+ * mode "login" — the Word? flow for returning users (the 1.0.x behavior).
34
+ * mode "signup" — the Get-your-word flow: opens WordKey's first-party
35
+ * onboarding page in a popup, which assigns a word, enrolls this device, and
36
+ * hands back the same single-use token login yields — so `onSuccess` has ONE
37
+ * contract across both modes and every WordKeyButton stays a thin wrapper.
38
+ */
39
+ interface WordKeyModalProps {
40
+ publishableKey: string;
41
+ /** Controlled visibility. The modal calls `onClose` when it dismisses itself
42
+ * (cancel, overlay click, or right before delivering `onSuccess`). */
43
+ open: boolean;
44
+ onClose: () => void;
45
+ onSuccess: (user: WordKeyUser) => void;
46
+ /** Which flow to open. Defaults to "login". */
47
+ mode?: WordKeyMode;
48
+ onError?: (error: WordKeyError) => void;
49
+ /** Profile fields to request. Standard names or custom field descriptors. */
50
+ requestedFields?: RequestedField[];
51
+ /** Override the protocol host (defaults to app.wordkey.app). */
52
+ apiUrl?: string;
53
+ }
54
+ declare function WordKeyModal({ publishableKey, open, onClose, onSuccess, mode, onError, requestedFields, apiUrl, }: WordKeyModalProps): react.JSX.Element | null;
55
+
56
+ export { RequestedField, WordKeyButton, type WordKeyButtonProps, WordKeyError, WordKeyModal, type WordKeyModalProps, WordKeyMode, WordKeyUser };
package/dist/index.js CHANGED
@@ -30,12 +30,13 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
30
30
  // src/index.ts
31
31
  var src_exports = {};
32
32
  __export(src_exports, {
33
- WordKeyButton: () => WordKeyButton
33
+ WordKeyButton: () => WordKeyButton,
34
+ WordKeyModal: () => WordKeyModal
34
35
  });
35
36
  module.exports = __toCommonJS(src_exports);
36
37
 
37
38
  // src/WordKeyButton.tsx
38
- var import_react = require("react");
39
+ var import_react2 = require("react");
39
40
 
40
41
  // src/types.ts
41
42
  var FIELD_LABELS = {
@@ -46,6 +47,9 @@ var FIELD_LABELS = {
46
47
  };
47
48
  var DEFAULT_API_URL = "https://app.wordkey.app";
48
49
 
50
+ // src/WordKeyModal.tsx
51
+ var import_react = require("react");
52
+
49
53
  // src/fields.ts
50
54
  var STANDARD = {
51
55
  name: { key: "name", label: FIELD_LABELS.name, type: "text", isStandard: true },
@@ -110,6 +114,17 @@ var DeviceApprovalRequiredError = class extends Error {
110
114
  this.name = "DeviceApprovalRequiredError";
111
115
  }
112
116
  };
117
+ function decodeToken(token) {
118
+ try {
119
+ const part = token.split(".")[1];
120
+ if (!part) return null;
121
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
122
+ const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
123
+ return JSON.parse(json);
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
113
128
  async function verifyWord(apiUrl = DEFAULT_API_URL, publishableKey, word) {
114
129
  const wordHash = await hashWord(word);
115
130
  const res = await fetch(`${apiUrl}/api/auth/verify`, {
@@ -244,6 +259,7 @@ async function beginRedirectShare(input2) {
244
259
  const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
245
260
  const privateKeyJwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
246
261
  const pending = {
262
+ kind: "login",
247
263
  requestId: input2.requestId,
248
264
  userId: input2.userId,
249
265
  isNewUser: input2.isNewUser,
@@ -283,10 +299,12 @@ async function consumePendingRedirect() {
283
299
  }
284
300
  if (envelope.requestId !== pending.requestId) return null;
285
301
  sessionStorage.removeItem(PENDING_STORE);
286
- if (envelope.granted !== true) {
287
- return { pending, result: { granted: false, values: {} } };
302
+ if (!envelope.epk || !envelope.iv || !envelope.ct) {
303
+ if (envelope.granted !== true) {
304
+ return { pending, result: { granted: false, values: {} } };
305
+ }
306
+ return null;
288
307
  }
289
- if (!envelope.epk || !envelope.iv || !envelope.ct) return null;
290
308
  try {
291
309
  const privateKey = await crypto.subtle.importKey(
292
310
  "jwk",
@@ -317,13 +335,135 @@ async function consumePendingRedirect() {
317
335
  const parsed = JSON.parse(new TextDecoder().decode(plain));
318
336
  return {
319
337
  pending,
320
- result: { granted: true, values: parsed.values ?? {} }
338
+ result: {
339
+ granted: envelope.granted === true,
340
+ values: parsed.values ?? {}
341
+ },
342
+ signup: typeof parsed.signup?.token === "string" ? { token: parsed.signup.token } : void 0
321
343
  };
322
344
  } catch {
323
345
  return null;
324
346
  }
325
347
  }
326
348
 
349
+ // src/signup-popup.ts
350
+ var SIGNUP_TIMEOUT_MS = 20 * 60 * 1e3;
351
+ function openSignupWindow(apiUrl, publishableKey) {
352
+ const w = 440;
353
+ const h = 700;
354
+ const left = Math.max(0, (window.screenX ?? 0) + (window.outerWidth - w) / 2);
355
+ const top = Math.max(0, (window.screenY ?? 0) + (window.outerHeight - h) / 3);
356
+ try {
357
+ return window.open(
358
+ `${apiUrl}/welcome?wk_sdk=1&business=${encodeURIComponent(publishableKey)}`,
359
+ "wordkey-signup",
360
+ `popup=yes,width=${w},height=${h},left=${left},top=${top}`
361
+ );
362
+ } catch {
363
+ return null;
364
+ }
365
+ }
366
+ function runSignupHandshake(input2) {
367
+ const { popup, apiOrigin, requestId, signal } = input2;
368
+ return new Promise((resolve) => {
369
+ let settled = false;
370
+ const cleanup = () => {
371
+ window.removeEventListener("message", onMsg);
372
+ clearInterval(closedPoll);
373
+ clearTimeout(timer);
374
+ signal?.removeEventListener("abort", onAbort);
375
+ };
376
+ const finish = (r) => {
377
+ if (settled) return;
378
+ settled = true;
379
+ cleanup();
380
+ resolve(r);
381
+ };
382
+ const onAbort = () => {
383
+ try {
384
+ popup.close();
385
+ } catch {
386
+ }
387
+ finish({ ok: false, cancelled: true });
388
+ };
389
+ const onMsg = (e) => {
390
+ if (e.origin !== apiOrigin || e.source !== popup) return;
391
+ const data = e.data;
392
+ if (!data || typeof data !== "object") return;
393
+ if (data.type === "wordkey:signup:ready") {
394
+ popup.postMessage(
395
+ {
396
+ type: "wordkey:signup:init",
397
+ requestId,
398
+ publishableKey: input2.publishableKey,
399
+ sdkDeviceId: input2.sdkDeviceId,
400
+ fields: input2.fields
401
+ },
402
+ apiOrigin
403
+ );
404
+ }
405
+ if (data.type === "wordkey:signup:result" && data.requestId === requestId) {
406
+ finish({
407
+ ok: data.ok === true && typeof data.token === "string",
408
+ token: typeof data.token === "string" ? data.token : void 0,
409
+ profileTicket: typeof data.profileTicket === "string" ? data.profileTicket : void 0
410
+ });
411
+ }
412
+ };
413
+ window.addEventListener("message", onMsg);
414
+ const closedPoll = setInterval(() => {
415
+ if (popup.closed) finish({ ok: false, closed: true });
416
+ }, 400);
417
+ const timer = setTimeout(
418
+ () => finish({ ok: false, closed: true }),
419
+ SIGNUP_TIMEOUT_MS
420
+ );
421
+ if (signal) {
422
+ if (signal.aborted) onAbort();
423
+ else signal.addEventListener("abort", onAbort);
424
+ }
425
+ });
426
+ }
427
+ async function beginSignupRedirect(input2) {
428
+ const pair = await crypto.subtle.generateKey(
429
+ { name: "ECDH", namedCurve: "P-256" },
430
+ true,
431
+ ["deriveKey"]
432
+ );
433
+ const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
434
+ const privateKeyJwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
435
+ const pending = {
436
+ kind: "signup",
437
+ requestId: input2.requestId,
438
+ fields: input2.fields,
439
+ privateKeyJwk
440
+ };
441
+ sessionStorage.setItem(PENDING_STORE, JSON.stringify(pending));
442
+ const returnUrl = window.location.href.split("#")[0];
443
+ const request = {
444
+ v: 1,
445
+ requestId: input2.requestId,
446
+ publishableKey: input2.publishableKey,
447
+ origin: window.location.origin,
448
+ returnUrl,
449
+ sdkPub: publicJwk,
450
+ sdkDeviceId: input2.sdkDeviceId,
451
+ fields: input2.fields
452
+ };
453
+ const b64 = encodeSignupRequest(request);
454
+ window.location.assign(
455
+ `${input2.apiUrl}/welcome?wk_sdk=1&business=${encodeURIComponent(
456
+ input2.publishableKey
457
+ )}#wksignup=${b64}`
458
+ );
459
+ }
460
+ function encodeSignupRequest(value) {
461
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
462
+ let bin = "";
463
+ for (const b of bytes) bin += String.fromCharCode(b);
464
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
465
+ }
466
+
327
467
  // src/fonts.ts
328
468
  var loaded = false;
329
469
  function ensureFont() {
@@ -420,19 +560,18 @@ var ghostBtn = {
420
560
  fontFamily: FONT_BODY
421
561
  };
422
562
 
423
- // src/WordKeyButton.tsx
563
+ // src/WordKeyModal.tsx
424
564
  var import_jsx_runtime = require("react/jsx-runtime");
425
- var sizePx = { sm: 20, md: 28, lg: 38 };
426
- function WordKeyButton({
565
+ function WordKeyModal({
427
566
  publishableKey,
567
+ open,
568
+ onClose,
428
569
  onSuccess,
570
+ mode = "login",
429
571
  onError,
430
- size = "md",
431
- label = "Word?",
432
572
  requestedFields,
433
573
  apiUrl = DEFAULT_API_URL
434
574
  }) {
435
- const [open, setOpen] = (0, import_react.useState)(false);
436
575
  (0, import_react.useEffect)(() => {
437
576
  ensureFont();
438
577
  }, []);
@@ -442,56 +581,151 @@ function WordKeyButton({
442
581
  resumed.current = true;
443
582
  void consumePendingRedirect().then((r) => {
444
583
  if (!r) return;
584
+ if (r.pending.kind === "signup") {
585
+ const token = r.signup?.token;
586
+ if (!token) {
587
+ onError?.({
588
+ code: "signup_incomplete",
589
+ message: "Signup did not complete. Please try again."
590
+ });
591
+ return;
592
+ }
593
+ const claims = decodeToken(token);
594
+ onSuccess({
595
+ id: claims?.userId ?? "",
596
+ isNewUser: claims?.isNewUser ?? true,
597
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
598
+ token,
599
+ profile: r.pending.fields.length ? buildProfile(r.pending.fields, r.result.granted ? r.result.values : {}) : void 0
600
+ });
601
+ return;
602
+ }
445
603
  const profile = buildProfile(
446
604
  r.pending.fields,
447
605
  r.result.granted ? r.result.values : {}
448
606
  );
449
607
  onSuccess({
450
- id: r.pending.userId,
451
- isNewUser: r.pending.isNewUser,
608
+ id: r.pending.userId ?? "",
609
+ isNewUser: r.pending.isNewUser ?? false,
452
610
  authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
453
611
  token: r.pending.token,
454
612
  profile
455
613
  });
456
614
  });
457
615
  }, []);
458
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
459
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
460
- "button",
461
- {
462
- onClick: () => setOpen(true),
463
- style: {
464
- background: "transparent",
465
- border: "none",
466
- padding: 0,
467
- cursor: "pointer",
468
- fontFamily: FONT_DISPLAY,
469
- fontWeight: 600,
470
- fontSize: sizePx[size],
471
- lineHeight: 1
472
- },
473
- "aria-label": label,
474
- children: [
475
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purple }, children: "Word" }),
476
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { color: C.purpleL }, children: "?" })
477
- ]
478
- }
479
- ),
480
- open && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
481
- AuthModal,
482
- {
483
- publishableKey,
616
+ if (!open) return null;
617
+ const common = {
618
+ publishableKey,
619
+ apiUrl,
620
+ requestedFields,
621
+ onClose,
622
+ onSuccess: (u) => {
623
+ onClose();
624
+ onSuccess(u);
625
+ },
626
+ onError: (e) => onError?.(e)
627
+ };
628
+ return mode === "signup" ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(SignupModal, { ...common }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AuthModal, { ...common });
629
+ }
630
+ function SignupModal({
631
+ publishableKey,
632
+ apiUrl,
633
+ requestedFields,
634
+ onClose,
635
+ onSuccess,
636
+ onError
637
+ }) {
638
+ const fields = (0, import_react.useMemo)(
639
+ () => requestedFields ? resolveFields(requestedFields) : [],
640
+ [requestedFields]
641
+ );
642
+ const apiOrigin = (0, import_react.useMemo)(() => {
643
+ try {
644
+ return new URL(apiUrl).origin;
645
+ } catch {
646
+ return apiUrl;
647
+ }
648
+ }, [apiUrl]);
649
+ const [failed, setFailed] = (0, import_react.useState)(null);
650
+ const abortRef = (0, import_react.useRef)(null);
651
+ if (!abortRef.current) abortRef.current = new AbortController();
652
+ (0, import_react.useEffect)(() => () => abortRef.current?.abort(), []);
653
+ const started = (0, import_react.useRef)(false);
654
+ (0, import_react.useEffect)(() => {
655
+ if (started.current) return;
656
+ started.current = true;
657
+ const popup = openSignupWindow(apiUrl, publishableKey);
658
+ if (!popup) {
659
+ void beginSignupRedirect({
484
660
  apiUrl,
485
- requestedFields,
486
- onClose: () => setOpen(false),
487
- onSuccess: (u) => {
488
- setOpen(false);
489
- onSuccess(u);
490
- },
491
- onError: (e) => onError?.(e)
661
+ requestId: newRequestId(),
662
+ publishableKey,
663
+ sdkDeviceId: getDeviceId(),
664
+ fields
665
+ });
666
+ return;
667
+ }
668
+ const requestId = newRequestId();
669
+ void (async () => {
670
+ const hs = await runSignupHandshake({
671
+ popup,
672
+ apiOrigin,
673
+ requestId,
674
+ publishableKey,
675
+ sdkDeviceId: getDeviceId(),
676
+ fields,
677
+ signal: abortRef.current?.signal
678
+ });
679
+ if (hs.cancelled) return;
680
+ if (hs.closed) {
681
+ onClose();
682
+ return;
492
683
  }
493
- )
494
- ] });
684
+ if (!hs.ok || !hs.token) {
685
+ setFailed("Signup could not be completed. Please try again.");
686
+ onError({
687
+ code: "signup_failed",
688
+ message: "Signup could not be completed"
689
+ });
690
+ return;
691
+ }
692
+ const claims = decodeToken(hs.token);
693
+ const finish = (profile) => onSuccess({
694
+ id: claims?.userId ?? "",
695
+ isNewUser: claims?.isNewUser ?? true,
696
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
697
+ token: hs.token,
698
+ profile
699
+ });
700
+ if (fields.length === 0) {
701
+ finish();
702
+ return;
703
+ }
704
+ const share = await runPopupFlow({
705
+ popup,
706
+ apiOrigin,
707
+ requestId,
708
+ publishableKey,
709
+ ticket: hs.profileTicket,
710
+ fields,
711
+ signal: abortRef.current?.signal
712
+ });
713
+ if (share.cancelled) return;
714
+ finish(buildProfile(fields, share.granted ? share.values : {}));
715
+ })();
716
+ }, []);
717
+ 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: [
718
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Header, {}),
719
+ failed ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
720
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorText, { children: failed }),
721
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Close" }) })
722
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: { textAlign: "center", padding: "12px 0" }, children: [
723
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, {}),
724
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h3", { style: { fontFamily: FONT_DISPLAY, fontSize: 22, margin: "4px 0" }, children: "Get your word in the WordKey window" }),
725
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", { style: { color: C.muted, fontSize: 13 }, children: "Finish setting up in the window that just opened. Closing it cancels \u2014 nothing is created without you." }),
726
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Row, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { style: ghostBtn, onClick: onClose, children: "Cancel" }) })
727
+ ] })
728
+ ] }) });
495
729
  }
496
730
  function AuthModal({
497
731
  publishableKey,
@@ -808,17 +1042,6 @@ function valuePresent(v) {
808
1042
  if (typeof v === "string") return v.trim().length > 0;
809
1043
  return Object.values(v).some((x) => x && String(x).trim().length > 0);
810
1044
  }
811
- function decodeToken(token) {
812
- try {
813
- const part = token.split(".")[1];
814
- if (!part) return null;
815
- const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
816
- const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
817
- return JSON.parse(json);
818
- } catch {
819
- return null;
820
- }
821
- }
822
1045
  function buildProfile(fields, granted) {
823
1046
  const profile = {};
824
1047
  const custom = {};
@@ -867,7 +1090,66 @@ function Spinner() {
867
1090
  }
868
1091
  );
869
1092
  }
1093
+
1094
+ // src/WordKeyButton.tsx
1095
+ var import_jsx_runtime2 = require("react/jsx-runtime");
1096
+ var sizePx = { sm: 20, md: 28, lg: 38 };
1097
+ function WordKeyButton({
1098
+ publishableKey,
1099
+ onSuccess,
1100
+ onError,
1101
+ size = "md",
1102
+ label,
1103
+ mode = "login",
1104
+ requestedFields,
1105
+ apiUrl = DEFAULT_API_URL
1106
+ }) {
1107
+ const [open, setOpen] = (0, import_react2.useState)(false);
1108
+ (0, import_react2.useEffect)(() => {
1109
+ ensureFont();
1110
+ }, []);
1111
+ return /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1112
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1113
+ "button",
1114
+ {
1115
+ onClick: () => setOpen(true),
1116
+ style: {
1117
+ background: "transparent",
1118
+ border: "none",
1119
+ padding: 0,
1120
+ cursor: "pointer",
1121
+ fontFamily: FONT_DISPLAY,
1122
+ fontWeight: 600,
1123
+ fontSize: sizePx[size],
1124
+ lineHeight: 1
1125
+ },
1126
+ "aria-label": label ?? (mode === "signup" ? "Get your word" : "Word?"),
1127
+ children: mode === "signup" ? /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1128
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purple }, children: "Get your " }),
1129
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purpleL }, children: "word" })
1130
+ ] }) : /* @__PURE__ */ (0, import_jsx_runtime2.jsxs)(import_jsx_runtime2.Fragment, { children: [
1131
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purple }, children: "Word" }),
1132
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("span", { style: { color: C.purpleL }, children: "?" })
1133
+ ] })
1134
+ }
1135
+ ),
1136
+ /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
1137
+ WordKeyModal,
1138
+ {
1139
+ publishableKey,
1140
+ open,
1141
+ mode,
1142
+ onClose: () => setOpen(false),
1143
+ onSuccess,
1144
+ onError,
1145
+ requestedFields,
1146
+ apiUrl
1147
+ }
1148
+ )
1149
+ ] });
1150
+ }
870
1151
  // Annotate the CommonJS export names for ESM import in node:
871
1152
  0 && (module.exports = {
872
- WordKeyButton
1153
+ WordKeyButton,
1154
+ WordKeyModal
873
1155
  });
package/dist/index.mjs CHANGED
@@ -4,6 +4,9 @@ import {
4
4
  } from "./chunk-JWCQBSIQ.mjs";
5
5
 
6
6
  // src/WordKeyButton.tsx
7
+ import { useEffect as useEffect2, useState as useState2 } from "react";
8
+
9
+ // src/WordKeyModal.tsx
7
10
  import { useEffect, useMemo, useRef, useState } from "react";
8
11
 
9
12
  // src/fields.ts
@@ -70,6 +73,17 @@ var DeviceApprovalRequiredError = class extends Error {
70
73
  this.name = "DeviceApprovalRequiredError";
71
74
  }
72
75
  };
76
+ function decodeToken(token) {
77
+ try {
78
+ const part = token.split(".")[1];
79
+ if (!part) return null;
80
+ const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
81
+ const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
82
+ return JSON.parse(json);
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
73
87
  async function verifyWord(apiUrl = DEFAULT_API_URL, publishableKey, word) {
74
88
  const wordHash = await hashWord(word);
75
89
  const res = await fetch(`${apiUrl}/api/auth/verify`, {
@@ -204,6 +218,7 @@ async function beginRedirectShare(input2) {
204
218
  const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
205
219
  const privateKeyJwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
206
220
  const pending = {
221
+ kind: "login",
207
222
  requestId: input2.requestId,
208
223
  userId: input2.userId,
209
224
  isNewUser: input2.isNewUser,
@@ -243,10 +258,12 @@ async function consumePendingRedirect() {
243
258
  }
244
259
  if (envelope.requestId !== pending.requestId) return null;
245
260
  sessionStorage.removeItem(PENDING_STORE);
246
- if (envelope.granted !== true) {
247
- return { pending, result: { granted: false, values: {} } };
261
+ if (!envelope.epk || !envelope.iv || !envelope.ct) {
262
+ if (envelope.granted !== true) {
263
+ return { pending, result: { granted: false, values: {} } };
264
+ }
265
+ return null;
248
266
  }
249
- if (!envelope.epk || !envelope.iv || !envelope.ct) return null;
250
267
  try {
251
268
  const privateKey = await crypto.subtle.importKey(
252
269
  "jwk",
@@ -277,13 +294,135 @@ async function consumePendingRedirect() {
277
294
  const parsed = JSON.parse(new TextDecoder().decode(plain));
278
295
  return {
279
296
  pending,
280
- result: { granted: true, values: parsed.values ?? {} }
297
+ result: {
298
+ granted: envelope.granted === true,
299
+ values: parsed.values ?? {}
300
+ },
301
+ signup: typeof parsed.signup?.token === "string" ? { token: parsed.signup.token } : void 0
281
302
  };
282
303
  } catch {
283
304
  return null;
284
305
  }
285
306
  }
286
307
 
308
+ // src/signup-popup.ts
309
+ var SIGNUP_TIMEOUT_MS = 20 * 60 * 1e3;
310
+ function openSignupWindow(apiUrl, publishableKey) {
311
+ const w = 440;
312
+ const h = 700;
313
+ const left = Math.max(0, (window.screenX ?? 0) + (window.outerWidth - w) / 2);
314
+ const top = Math.max(0, (window.screenY ?? 0) + (window.outerHeight - h) / 3);
315
+ try {
316
+ return window.open(
317
+ `${apiUrl}/welcome?wk_sdk=1&business=${encodeURIComponent(publishableKey)}`,
318
+ "wordkey-signup",
319
+ `popup=yes,width=${w},height=${h},left=${left},top=${top}`
320
+ );
321
+ } catch {
322
+ return null;
323
+ }
324
+ }
325
+ function runSignupHandshake(input2) {
326
+ const { popup, apiOrigin, requestId, signal } = input2;
327
+ return new Promise((resolve) => {
328
+ let settled = false;
329
+ const cleanup = () => {
330
+ window.removeEventListener("message", onMsg);
331
+ clearInterval(closedPoll);
332
+ clearTimeout(timer);
333
+ signal?.removeEventListener("abort", onAbort);
334
+ };
335
+ const finish = (r) => {
336
+ if (settled) return;
337
+ settled = true;
338
+ cleanup();
339
+ resolve(r);
340
+ };
341
+ const onAbort = () => {
342
+ try {
343
+ popup.close();
344
+ } catch {
345
+ }
346
+ finish({ ok: false, cancelled: true });
347
+ };
348
+ const onMsg = (e) => {
349
+ if (e.origin !== apiOrigin || e.source !== popup) return;
350
+ const data = e.data;
351
+ if (!data || typeof data !== "object") return;
352
+ if (data.type === "wordkey:signup:ready") {
353
+ popup.postMessage(
354
+ {
355
+ type: "wordkey:signup:init",
356
+ requestId,
357
+ publishableKey: input2.publishableKey,
358
+ sdkDeviceId: input2.sdkDeviceId,
359
+ fields: input2.fields
360
+ },
361
+ apiOrigin
362
+ );
363
+ }
364
+ if (data.type === "wordkey:signup:result" && data.requestId === requestId) {
365
+ finish({
366
+ ok: data.ok === true && typeof data.token === "string",
367
+ token: typeof data.token === "string" ? data.token : void 0,
368
+ profileTicket: typeof data.profileTicket === "string" ? data.profileTicket : void 0
369
+ });
370
+ }
371
+ };
372
+ window.addEventListener("message", onMsg);
373
+ const closedPoll = setInterval(() => {
374
+ if (popup.closed) finish({ ok: false, closed: true });
375
+ }, 400);
376
+ const timer = setTimeout(
377
+ () => finish({ ok: false, closed: true }),
378
+ SIGNUP_TIMEOUT_MS
379
+ );
380
+ if (signal) {
381
+ if (signal.aborted) onAbort();
382
+ else signal.addEventListener("abort", onAbort);
383
+ }
384
+ });
385
+ }
386
+ async function beginSignupRedirect(input2) {
387
+ const pair = await crypto.subtle.generateKey(
388
+ { name: "ECDH", namedCurve: "P-256" },
389
+ true,
390
+ ["deriveKey"]
391
+ );
392
+ const publicJwk = await crypto.subtle.exportKey("jwk", pair.publicKey);
393
+ const privateKeyJwk = await crypto.subtle.exportKey("jwk", pair.privateKey);
394
+ const pending = {
395
+ kind: "signup",
396
+ requestId: input2.requestId,
397
+ fields: input2.fields,
398
+ privateKeyJwk
399
+ };
400
+ sessionStorage.setItem(PENDING_STORE, JSON.stringify(pending));
401
+ const returnUrl = window.location.href.split("#")[0];
402
+ const request = {
403
+ v: 1,
404
+ requestId: input2.requestId,
405
+ publishableKey: input2.publishableKey,
406
+ origin: window.location.origin,
407
+ returnUrl,
408
+ sdkPub: publicJwk,
409
+ sdkDeviceId: input2.sdkDeviceId,
410
+ fields: input2.fields
411
+ };
412
+ const b64 = encodeSignupRequest(request);
413
+ window.location.assign(
414
+ `${input2.apiUrl}/welcome?wk_sdk=1&business=${encodeURIComponent(
415
+ input2.publishableKey
416
+ )}#wksignup=${b64}`
417
+ );
418
+ }
419
+ function encodeSignupRequest(value) {
420
+ const bytes = new TextEncoder().encode(JSON.stringify(value));
421
+ let bin = "";
422
+ for (const b of bytes) bin += String.fromCharCode(b);
423
+ return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
424
+ }
425
+
287
426
  // src/fonts.ts
288
427
  var loaded = false;
289
428
  function ensureFont() {
@@ -380,19 +519,18 @@ var ghostBtn = {
380
519
  fontFamily: FONT_BODY
381
520
  };
382
521
 
383
- // src/WordKeyButton.tsx
522
+ // src/WordKeyModal.tsx
384
523
  import { Fragment, jsx, jsxs } from "react/jsx-runtime";
385
- var sizePx = { sm: 20, md: 28, lg: 38 };
386
- function WordKeyButton({
524
+ function WordKeyModal({
387
525
  publishableKey,
526
+ open,
527
+ onClose,
388
528
  onSuccess,
529
+ mode = "login",
389
530
  onError,
390
- size = "md",
391
- label = "Word?",
392
531
  requestedFields,
393
532
  apiUrl = DEFAULT_API_URL
394
533
  }) {
395
- const [open, setOpen] = useState(false);
396
534
  useEffect(() => {
397
535
  ensureFont();
398
536
  }, []);
@@ -402,56 +540,151 @@ function WordKeyButton({
402
540
  resumed.current = true;
403
541
  void consumePendingRedirect().then((r) => {
404
542
  if (!r) return;
543
+ if (r.pending.kind === "signup") {
544
+ const token = r.signup?.token;
545
+ if (!token) {
546
+ onError?.({
547
+ code: "signup_incomplete",
548
+ message: "Signup did not complete. Please try again."
549
+ });
550
+ return;
551
+ }
552
+ const claims = decodeToken(token);
553
+ onSuccess({
554
+ id: claims?.userId ?? "",
555
+ isNewUser: claims?.isNewUser ?? true,
556
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
557
+ token,
558
+ profile: r.pending.fields.length ? buildProfile(r.pending.fields, r.result.granted ? r.result.values : {}) : void 0
559
+ });
560
+ return;
561
+ }
405
562
  const profile = buildProfile(
406
563
  r.pending.fields,
407
564
  r.result.granted ? r.result.values : {}
408
565
  );
409
566
  onSuccess({
410
- id: r.pending.userId,
411
- isNewUser: r.pending.isNewUser,
567
+ id: r.pending.userId ?? "",
568
+ isNewUser: r.pending.isNewUser ?? false,
412
569
  authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
413
570
  token: r.pending.token,
414
571
  profile
415
572
  });
416
573
  });
417
574
  }, []);
418
- return /* @__PURE__ */ jsxs(Fragment, { children: [
419
- /* @__PURE__ */ jsxs(
420
- "button",
421
- {
422
- onClick: () => setOpen(true),
423
- style: {
424
- background: "transparent",
425
- border: "none",
426
- padding: 0,
427
- cursor: "pointer",
428
- fontFamily: FONT_DISPLAY,
429
- fontWeight: 600,
430
- fontSize: sizePx[size],
431
- lineHeight: 1
432
- },
433
- "aria-label": label,
434
- children: [
435
- /* @__PURE__ */ jsx("span", { style: { color: C.purple }, children: "Word" }),
436
- /* @__PURE__ */ jsx("span", { style: { color: C.purpleL }, children: "?" })
437
- ]
438
- }
439
- ),
440
- open && /* @__PURE__ */ jsx(
441
- AuthModal,
442
- {
443
- publishableKey,
575
+ if (!open) return null;
576
+ const common = {
577
+ publishableKey,
578
+ apiUrl,
579
+ requestedFields,
580
+ onClose,
581
+ onSuccess: (u) => {
582
+ onClose();
583
+ onSuccess(u);
584
+ },
585
+ onError: (e) => onError?.(e)
586
+ };
587
+ return mode === "signup" ? /* @__PURE__ */ jsx(SignupModal, { ...common }) : /* @__PURE__ */ jsx(AuthModal, { ...common });
588
+ }
589
+ function SignupModal({
590
+ publishableKey,
591
+ apiUrl,
592
+ requestedFields,
593
+ onClose,
594
+ onSuccess,
595
+ onError
596
+ }) {
597
+ const fields = useMemo(
598
+ () => requestedFields ? resolveFields(requestedFields) : [],
599
+ [requestedFields]
600
+ );
601
+ const apiOrigin = useMemo(() => {
602
+ try {
603
+ return new URL(apiUrl).origin;
604
+ } catch {
605
+ return apiUrl;
606
+ }
607
+ }, [apiUrl]);
608
+ const [failed, setFailed] = useState(null);
609
+ const abortRef = useRef(null);
610
+ if (!abortRef.current) abortRef.current = new AbortController();
611
+ useEffect(() => () => abortRef.current?.abort(), []);
612
+ const started = useRef(false);
613
+ useEffect(() => {
614
+ if (started.current) return;
615
+ started.current = true;
616
+ const popup = openSignupWindow(apiUrl, publishableKey);
617
+ if (!popup) {
618
+ void beginSignupRedirect({
444
619
  apiUrl,
445
- requestedFields,
446
- onClose: () => setOpen(false),
447
- onSuccess: (u) => {
448
- setOpen(false);
449
- onSuccess(u);
450
- },
451
- onError: (e) => onError?.(e)
620
+ requestId: newRequestId(),
621
+ publishableKey,
622
+ sdkDeviceId: getDeviceId(),
623
+ fields
624
+ });
625
+ return;
626
+ }
627
+ const requestId = newRequestId();
628
+ void (async () => {
629
+ const hs = await runSignupHandshake({
630
+ popup,
631
+ apiOrigin,
632
+ requestId,
633
+ publishableKey,
634
+ sdkDeviceId: getDeviceId(),
635
+ fields,
636
+ signal: abortRef.current?.signal
637
+ });
638
+ if (hs.cancelled) return;
639
+ if (hs.closed) {
640
+ onClose();
641
+ return;
452
642
  }
453
- )
454
- ] });
643
+ if (!hs.ok || !hs.token) {
644
+ setFailed("Signup could not be completed. Please try again.");
645
+ onError({
646
+ code: "signup_failed",
647
+ message: "Signup could not be completed"
648
+ });
649
+ return;
650
+ }
651
+ const claims = decodeToken(hs.token);
652
+ const finish = (profile) => onSuccess({
653
+ id: claims?.userId ?? "",
654
+ isNewUser: claims?.isNewUser ?? true,
655
+ authenticatedAt: (/* @__PURE__ */ new Date()).toISOString(),
656
+ token: hs.token,
657
+ profile
658
+ });
659
+ if (fields.length === 0) {
660
+ finish();
661
+ return;
662
+ }
663
+ const share = await runPopupFlow({
664
+ popup,
665
+ apiOrigin,
666
+ requestId,
667
+ publishableKey,
668
+ ticket: hs.profileTicket,
669
+ fields,
670
+ signal: abortRef.current?.signal
671
+ });
672
+ if (share.cancelled) return;
673
+ finish(buildProfile(fields, share.granted ? share.values : {}));
674
+ })();
675
+ }, []);
676
+ return /* @__PURE__ */ jsx("div", { style: overlay, role: "dialog", "aria-modal": "true", onClick: onClose, children: /* @__PURE__ */ jsxs("div", { style: panel, onClick: (e) => e.stopPropagation(), children: [
677
+ /* @__PURE__ */ jsx(Header, {}),
678
+ failed ? /* @__PURE__ */ jsxs(Fragment, { children: [
679
+ /* @__PURE__ */ jsx(ErrorText, { children: failed }),
680
+ /* @__PURE__ */ jsx(Row, { children: /* @__PURE__ */ jsx("button", { style: ghostBtn, onClick: onClose, children: "Close" }) })
681
+ ] }) : /* @__PURE__ */ jsxs("div", { style: { textAlign: "center", padding: "12px 0" }, children: [
682
+ /* @__PURE__ */ jsx(Spinner, {}),
683
+ /* @__PURE__ */ jsx("h3", { style: { fontFamily: FONT_DISPLAY, fontSize: 22, margin: "4px 0" }, children: "Get your word in the WordKey window" }),
684
+ /* @__PURE__ */ jsx("p", { style: { color: C.muted, fontSize: 13 }, children: "Finish setting up in the window that just opened. Closing it cancels \u2014 nothing is created without you." }),
685
+ /* @__PURE__ */ jsx(Row, { children: /* @__PURE__ */ jsx("button", { style: ghostBtn, onClick: onClose, children: "Cancel" }) })
686
+ ] })
687
+ ] }) });
455
688
  }
456
689
  function AuthModal({
457
690
  publishableKey,
@@ -768,17 +1001,6 @@ function valuePresent(v) {
768
1001
  if (typeof v === "string") return v.trim().length > 0;
769
1002
  return Object.values(v).some((x) => x && String(x).trim().length > 0);
770
1003
  }
771
- function decodeToken(token) {
772
- try {
773
- const part = token.split(".")[1];
774
- if (!part) return null;
775
- const b64 = part.replace(/-/g, "+").replace(/_/g, "/");
776
- const json = typeof atob === "function" ? atob(b64) : Buffer.from(b64, "base64").toString("utf8");
777
- return JSON.parse(json);
778
- } catch {
779
- return null;
780
- }
781
- }
782
1004
  function buildProfile(fields, granted) {
783
1005
  const profile = {};
784
1006
  const custom = {};
@@ -827,6 +1049,65 @@ function Spinner() {
827
1049
  }
828
1050
  );
829
1051
  }
1052
+
1053
+ // src/WordKeyButton.tsx
1054
+ import { Fragment as Fragment2, jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
1055
+ var sizePx = { sm: 20, md: 28, lg: 38 };
1056
+ function WordKeyButton({
1057
+ publishableKey,
1058
+ onSuccess,
1059
+ onError,
1060
+ size = "md",
1061
+ label,
1062
+ mode = "login",
1063
+ requestedFields,
1064
+ apiUrl = DEFAULT_API_URL
1065
+ }) {
1066
+ const [open, setOpen] = useState2(false);
1067
+ useEffect2(() => {
1068
+ ensureFont();
1069
+ }, []);
1070
+ return /* @__PURE__ */ jsxs2(Fragment2, { children: [
1071
+ /* @__PURE__ */ jsx2(
1072
+ "button",
1073
+ {
1074
+ onClick: () => setOpen(true),
1075
+ style: {
1076
+ background: "transparent",
1077
+ border: "none",
1078
+ padding: 0,
1079
+ cursor: "pointer",
1080
+ fontFamily: FONT_DISPLAY,
1081
+ fontWeight: 600,
1082
+ fontSize: sizePx[size],
1083
+ lineHeight: 1
1084
+ },
1085
+ "aria-label": label ?? (mode === "signup" ? "Get your word" : "Word?"),
1086
+ children: mode === "signup" ? /* @__PURE__ */ jsxs2(Fragment2, { children: [
1087
+ /* @__PURE__ */ jsx2("span", { style: { color: C.purple }, children: "Get your " }),
1088
+ /* @__PURE__ */ jsx2("span", { style: { color: C.purpleL }, children: "word" })
1089
+ ] }) : /* @__PURE__ */ jsxs2(Fragment2, { children: [
1090
+ /* @__PURE__ */ jsx2("span", { style: { color: C.purple }, children: "Word" }),
1091
+ /* @__PURE__ */ jsx2("span", { style: { color: C.purpleL }, children: "?" })
1092
+ ] })
1093
+ }
1094
+ ),
1095
+ /* @__PURE__ */ jsx2(
1096
+ WordKeyModal,
1097
+ {
1098
+ publishableKey,
1099
+ open,
1100
+ mode,
1101
+ onClose: () => setOpen(false),
1102
+ onSuccess,
1103
+ onError,
1104
+ requestedFields,
1105
+ apiUrl
1106
+ }
1107
+ )
1108
+ ] });
1109
+ }
830
1110
  export {
831
- WordKeyButton
1111
+ WordKeyButton,
1112
+ WordKeyModal
832
1113
  };
package/dist/server.d.mts CHANGED
@@ -1,5 +1,5 @@
1
- import { W as WordKeyUser, b as AuthEvent } from './types-B4z3Zton.mjs';
2
- export { A as Address, a as WordKeyError, c as WordKeyProfile } from './types-B4z3Zton.mjs';
1
+ import { W as WordKeyUser, c as AuthEvent } from './types-WFjkfsy6.mjs';
2
+ export { A as Address, a as WordKeyError, d as WordKeyProfile } from './types-WFjkfsy6.mjs';
3
3
 
4
4
  interface WordKeyConfig {
5
5
  /** Your SECRET key (wk_sec_...). Server-only — never ship it to a browser. */
package/dist/server.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import { W as WordKeyUser, b as AuthEvent } from './types-B4z3Zton.js';
2
- export { A as Address, a as WordKeyError, c as WordKeyProfile } from './types-B4z3Zton.js';
1
+ import { W as WordKeyUser, c as AuthEvent } from './types-WFjkfsy6.js';
2
+ export { A as Address, a as WordKeyError, d as WordKeyProfile } from './types-WFjkfsy6.js';
3
3
 
4
4
  interface WordKeyConfig {
5
5
  /** Your SECRET key (wk_sec_...). Server-only — never ship it to a browser. */
@@ -45,6 +45,13 @@ interface WordKeyUser {
45
45
  /** Present only when requestedFields were granted; unfilled fields omitted. */
46
46
  profile?: WordKeyProfile;
47
47
  }
48
+ /**
49
+ * The two entry points of the protocol. "login" is Word? — a returning user
50
+ * types the word they already have. "signup" is Get-your-word — WordKey's
51
+ * first-party onboarding assigns a word, enrolls the device, and signs the
52
+ * new user in to your site in one flow. Both deliver the same WordKeyUser.
53
+ */
54
+ type WordKeyMode = "login" | "signup";
48
55
  interface WordKeyError {
49
56
  code: string;
50
57
  message: string;
@@ -55,4 +62,4 @@ interface AuthEvent {
55
62
  at: string | null;
56
63
  }
57
64
 
58
- export type { Address as A, CustomField as C, ProfileField as P, RequestedField as R, WordKeyUser as W, WordKeyError as a, AuthEvent as b, WordKeyProfile as c };
65
+ export type { Address as A, CustomField as C, ProfileField as P, RequestedField as R, WordKeyUser as W, WordKeyError as a, WordKeyMode as b, AuthEvent as c, WordKeyProfile as d };
@@ -45,6 +45,13 @@ interface WordKeyUser {
45
45
  /** Present only when requestedFields were granted; unfilled fields omitted. */
46
46
  profile?: WordKeyProfile;
47
47
  }
48
+ /**
49
+ * The two entry points of the protocol. "login" is Word? — a returning user
50
+ * types the word they already have. "signup" is Get-your-word — WordKey's
51
+ * first-party onboarding assigns a word, enrolls the device, and signs the
52
+ * new user in to your site in one flow. Both deliver the same WordKeyUser.
53
+ */
54
+ type WordKeyMode = "login" | "signup";
48
55
  interface WordKeyError {
49
56
  code: string;
50
57
  message: string;
@@ -55,4 +62,4 @@ interface AuthEvent {
55
62
  at: string | null;
56
63
  }
57
64
 
58
- export type { Address as A, CustomField as C, ProfileField as P, RequestedField as R, WordKeyUser as W, WordKeyError as a, AuthEvent as b, WordKeyProfile as c };
65
+ export type { Address as A, CustomField as C, ProfileField as P, RequestedField as R, WordKeyUser as W, WordKeyError as a, WordKeyMode as b, AuthEvent as c, WordKeyProfile as d };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wordkey/sdk",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Add the Word? button to your site in one afternoon. One word. Every door.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "main": "./dist/index.js",