@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 +66 -14
- package/dist/index.d.mts +41 -4
- package/dist/index.d.ts +41 -4
- package/dist/index.js +343 -61
- package/dist/index.mjs +340 -59
- package/dist/server.d.mts +2 -2
- package/dist/server.d.ts +2 -2
- package/dist/{types-B4z3Zton.d.mts → types-WFjkfsy6.d.mts} +8 -1
- package/dist/{types-B4z3Zton.d.ts → types-WFjkfsy6.d.ts} +8 -1
- package/package.json +1 -1
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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-
|
|
3
|
-
export { A as Address,
|
|
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
|
-
|
|
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-
|
|
3
|
-
export { A as Address,
|
|
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
|
-
|
|
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
|
|
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.
|
|
287
|
-
|
|
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: {
|
|
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/
|
|
563
|
+
// src/WordKeyModal.tsx
|
|
424
564
|
var import_jsx_runtime = require("react/jsx-runtime");
|
|
425
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
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.
|
|
247
|
-
|
|
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: {
|
|
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/
|
|
522
|
+
// src/WordKeyModal.tsx
|
|
384
523
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
385
|
-
|
|
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
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
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,
|
|
2
|
-
export { A as Address, a as WordKeyError,
|
|
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,
|
|
2
|
-
export { A as Address, a as WordKeyError,
|
|
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,
|
|
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,
|
|
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 };
|