create-ailk 0.32.0 → 0.33.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.
Files changed (46) hide show
  1. package/README.md +9 -3
  2. package/component-catalog.json +12 -1
  3. package/component-catalog.md +6 -5
  4. package/dist/export-content.d.ts +6 -3
  5. package/dist/export-content.js +79 -10
  6. package/dist/lib/import-mdx.d.ts +42 -0
  7. package/dist/lib/import-mdx.js +531 -45
  8. package/dist/seed-flows.d.ts +1 -1
  9. package/dist/seed-flows.js +9 -4
  10. package/dist/surfaces.js +1 -0
  11. package/package.json +3 -3
  12. package/templates/apps/api/CLAUDE.md +7 -5
  13. package/templates/apps/api/src/lib/__mocks__/prisma.ts +13 -0
  14. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +28 -0
  15. package/templates/apps/web/app/[locale]/(auth)/verify-email/__tests__/actions.test.ts +94 -0
  16. package/templates/apps/web/app/[locale]/(auth)/verify-email/actions.ts +77 -0
  17. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +79 -14
  18. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +7 -0
  19. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +7 -0
  20. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +7 -0
  21. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +21 -0
  22. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +15 -9
  23. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +38 -105
  24. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +72 -58
  25. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/verify.test.tsx +249 -0
  26. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/ResultsClient.tsx +164 -20
  27. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/by-result.test.tsx +269 -0
  28. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/__tests__/score.test.tsx +179 -0
  29. package/templates/apps/web/app/[locale]/flows/[slug]/results/[payload]/page.tsx +108 -40
  30. package/templates/apps/web/app/[locale]/flows/[slug]/verify/page.tsx +153 -0
  31. package/templates/apps/web/app/[locale]/flows/[slug]/verify.actions.ts +151 -0
  32. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +1 -1
  33. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -20
  34. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +2 -9
  35. package/templates/apps/web/auth.ts +22 -6
  36. package/templates/apps/web/lib/__tests__/account-config.test.ts +69 -0
  37. package/templates/apps/web/lib/__tests__/auth-email.test.ts +99 -1
  38. package/templates/apps/web/lib/__tests__/telemetry-scrub.test.ts +20 -0
  39. package/templates/apps/web/lib/account-config.ts +52 -14
  40. package/templates/apps/web/lib/auth-email.ts +87 -5
  41. package/templates/apps/web/lib/telemetry-scrub.ts +6 -0
  42. package/templates/apps/web/messages/en.json +26 -1
  43. package/templates/database/seed.ts +7 -7
  44. package/versions.json +48 -48
  45. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +0 -89
  46. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +0 -104
@@ -319,13 +319,30 @@ export function makeSendResetPassword(deps: ResetPasswordDeps) {
319
319
  };
320
320
  }
321
321
 
322
- /** Better Auth's `sendVerificationEmail` (§ D9). */
323
- export function makeSendVerificationEmail(deps: DispatchDeps) {
324
- const { send } = deps;
322
+ interface VerificationDeps extends DispatchDeps {
323
+ /**
324
+ * The site's own confirm page — `new URL("/verify-email", BETTER_AUTH_URL)`
325
+ * — which the API's email-verification service appends its single-use nonce
326
+ * to (#10820 D4).
327
+ */
328
+ confirmBase: string;
329
+ }
330
+
331
+ /**
332
+ * Better Auth's `sendVerificationEmail` (§ D9, #10820 D4).
333
+ *
334
+ * Keeps the library's signature and IGNORES its `url`: that link is Better
335
+ * Auth's stateless JWT, which is neither single-use nor revocable, and its
336
+ * `/verify-email` endpoint is disabled in `auth.ts`. The relay's
337
+ * `email-verification` kind mints the service's own nonce for this user and
338
+ * mails `confirmBase?verify=<nonce>`; a click on that page confirms through
339
+ * `confirmSignUpEmail` below.
340
+ */
341
+ export function makeSendVerificationEmail(deps: VerificationDeps) {
342
+ const { send, confirmBase } = deps;
325
343
  const log = deps.log ?? defaultLog;
326
344
  return async function sendVerificationEmail({
327
345
  user,
328
- url,
329
346
  }: {
330
347
  user: { id: string; email: string };
331
348
  url: string;
@@ -335,7 +352,7 @@ export function makeSendVerificationEmail(deps: DispatchDeps) {
335
352
  kind: "email-verification",
336
353
  userId: user.id,
337
354
  to: user.email,
338
- url,
355
+ url: confirmBase,
339
356
  expiresInMinutes: 24 * 60,
340
357
  },
341
358
  { send, log },
@@ -343,6 +360,71 @@ export function makeSendVerificationEmail(deps: DispatchDeps) {
343
360
  };
344
361
  }
345
362
 
363
+ /** The confirm hop's answer, as the `/verify-email` action reads it. */
364
+ export type SignUpConfirmOutcome =
365
+ | "verified"
366
+ | "already_verified"
367
+ | "expired"
368
+ | "invalid"
369
+ | "failed";
370
+
371
+ /** The confirm hop's transport seam — `authEmail.confirm`'s shape. */
372
+ export type ConfirmTransport = (
373
+ rawBody: string,
374
+ headers: Record<string, string>,
375
+ ) => Promise<{ ok: boolean; body?: unknown }>;
376
+
377
+ const CONFIRM_OUTCOMES = new Set(["verified", "already_verified", "expired", "invalid"]);
378
+
379
+ /**
380
+ * Confirm a sign-up link's nonce through the API's signed hop,
381
+ * `POST /v1/auth/email-verification/confirm` (#10820 D2, D7).
382
+ *
383
+ * ONE serialization of `{ nonce }`, signed over the DOMAIN-SEPARATED base
384
+ * string `email-verification-confirm.<timestamp>.<rawBody>` — so a body signed
385
+ * for the mail relay can never be replayed to the confirm route, or the
386
+ * reverse — and the same bytes handed to the transport.
387
+ *
388
+ * NEVER THROWS, and logs only `{ kind, outcome | errorKind }`: never the
389
+ * nonce, which is the credential. Anything but a 2xx carrying a known outcome
390
+ * is `failed`.
391
+ */
392
+ export async function confirmSignUpEmail(
393
+ nonce: string,
394
+ deps: { confirm?: ConfirmTransport; log?: AuthEmailLog } = {},
395
+ ): Promise<SignUpConfirmOutcome> {
396
+ const confirm: ConfirmTransport =
397
+ deps.confirm ?? ((rawBody, headers) => authEmail.confirm(rawBody, headers));
398
+ const log = deps.log ?? defaultLog;
399
+ const base = { kind: "email-verification-confirm" };
400
+ const secret = process.env.AUTH_EMAIL_WEBHOOK_SECRET;
401
+ if (!secret) {
402
+ log.warn({ ...base, errorKind: "not_configured" });
403
+ return "failed";
404
+ }
405
+ try {
406
+ const rawBody = JSON.stringify({ nonce });
407
+ const timestamp = String(Math.floor(Date.now() / 1000));
408
+ const signature = `sha256=${createHmac("sha256", secret)
409
+ .update(`email-verification-confirm.${timestamp}.${rawBody}`, "utf8")
410
+ .digest("hex")}`;
411
+ const result = await confirm(rawBody, {
412
+ "x-ailk-auth-email-signature": signature,
413
+ "x-ailk-auth-email-timestamp": timestamp,
414
+ });
415
+ const outcome = (result.body as { outcome?: unknown } | null | undefined)?.outcome;
416
+ if (!result.ok || typeof outcome !== "string" || !CONFIRM_OUTCOMES.has(outcome)) {
417
+ log.warn({ ...base, errorKind: "relay_rejected" });
418
+ return "failed";
419
+ }
420
+ log.info({ ...base, outcome });
421
+ return outcome as SignUpConfirmOutcome;
422
+ } catch {
423
+ log.warn({ ...base, errorKind: "transport_error" });
424
+ return "failed";
425
+ }
426
+ }
427
+
346
428
  /**
347
429
  * The completed-reset notice (§ D17).
348
430
  *
@@ -19,12 +19,18 @@
19
19
  // Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
20
20
  // since apps/* must not import @working-theory/* packages — keep both lists
21
21
  // in sync (#5613).
22
+ // `verify` joined by #10820 (D2): the email-verification nonce on a
23
+ // `/verify-email` or listing resume link.
24
+ // `lead` joined by #10826 (D5, SEC-10826-7): a magnet Lead's id on its
25
+ // `/flows/<slug>/verify?lead=…&verify=…` confirm link.
22
26
  export const TOKEN_QUERY_PARAMS = [
23
27
  "g",
24
28
  "session_id",
25
29
  "email",
26
30
  "login_hint",
27
31
  "ref",
32
+ "verify",
33
+ "lead",
28
34
  ];
29
35
 
30
36
  // The same grant id rides a PATH too: `deliverables.openGrant` fetches
@@ -80,6 +80,7 @@
80
80
  "heading": "Blog",
81
81
  "emptyState": "No posts yet. Drop MDX files into",
82
82
  "emptyStateTrailing": "to populate this list.",
83
+ "emptyStateLive": "No posts yet.",
83
84
  "allTopics": "All",
84
85
  "topicsNav": "Blog topics",
85
86
  "latestPosts": "Latest posts",
@@ -254,6 +255,20 @@
254
255
  },
255
256
  "questions": {}
256
257
  },
258
+ "flowVerify": {
259
+ "title": "Confirm your email",
260
+ "confirmHeading": "Confirm your email",
261
+ "confirmSubhead": "One click and your address is confirmed.",
262
+ "confirmBody": "Select the button below to confirm this email address.",
263
+ "confirmButton": "Confirm my email",
264
+ "confirmedHeading": "Your email is confirmed",
265
+ "confirmedSubhead": "Thanks — your address is verified.",
266
+ "confirmedBody": "You can close this tab once you're done.",
267
+ "errorHeading": "This confirmation link has expired",
268
+ "errorSubhead": "Confirmation links last 24 hours.",
269
+ "errorBody": "Answer the form again and we'll send you a fresh confirmation email.",
270
+ "back": "Back to the form"
271
+ },
257
272
  "how-to": {
258
273
  "heading": "How-To Guides",
259
274
  "description": "Step-by-step guides for building with this site.",
@@ -665,6 +680,13 @@
665
680
  "verifyExpiredHeading": "This confirmation link has expired",
666
681
  "verifyExpiredSubhead": "Confirmation links last 24 hours.",
667
682
  "verifyExpiredBody": "Sign in again and we'll send you a fresh confirmation email.",
683
+ "confirmHeading": "Confirm your email",
684
+ "confirmSubhead": "One click and your address is confirmed.",
685
+ "confirmBody": "Select the button below to confirm this email address for your account.",
686
+ "confirmButton": "Confirm my email",
687
+ "verifiedHeading": "Your email is confirmed",
688
+ "verifiedSubhead": "Thanks — your address is verified.",
689
+ "verifiedBody": "You can sign in now. You can close this tab once you're done.",
668
690
  "sentHeading": "Check your email",
669
691
  "sentSubhead": "Follow the link we just sent to continue.",
670
692
  "sentBody": "Check your inbox and follow the link to continue. You can close this tab once you're done.",
@@ -969,7 +991,10 @@
969
991
  "tier": "Tier",
970
992
  "score": "Score",
971
993
  "completedAt": "Completed",
972
- "unavailable": "—"
994
+ "unavailable": "—",
995
+ "magnet": "Form",
996
+ "submittedAt": "Submitted",
997
+ "open": "Open"
973
998
  },
974
999
  "purchases": {
975
1000
  "heading": "My purchases",
@@ -149,8 +149,8 @@ export async function provisionDefaultWorkspace(
149
149
  kind: "contact",
150
150
  title: REQUEST_QUOTE_FLOW_TITLE,
151
151
  status: "draft",
152
- customersEnabled: true,
153
- completion: "invite",
152
+ who: "user",
153
+ completion: "user",
154
154
  tags: [STANDARD_MAGNET_TAGS[REQUEST_QUOTE_FLOW_SLUG]!],
155
155
  },
156
156
  update: {},
@@ -171,7 +171,7 @@ export async function provisionDefaultWorkspace(
171
171
  kind: "contact",
172
172
  title: CONTACT_FORM_FLOW_TITLE,
173
173
  status: "live",
174
- customersEnabled: false,
174
+ who: "visitor",
175
175
  completion: "lead",
176
176
  tags: [STANDARD_MAGNET_TAGS[CONTACT_FORM_FLOW_SLUG]!],
177
177
  landingPage: CONTACT_FORM_LANDING_PAGE,
@@ -194,8 +194,8 @@ export async function provisionDefaultWorkspace(
194
194
  kind: "diagnostic",
195
195
  title: JOB_APPLICATION_FLOW_TITLE,
196
196
  status: "live",
197
- customersEnabled: false,
198
- completion: "invite",
197
+ who: "visitor",
198
+ completion: "user",
199
199
  tags: [STANDARD_MAGNET_TAGS[JOB_APPLICATION_FLOW_SLUG]!],
200
200
  landingPage: JOB_APPLICATION_LANDING_PAGE,
201
201
  },
@@ -218,7 +218,7 @@ export async function provisionDefaultWorkspace(
218
218
  kind: "newsletter",
219
219
  title: NEWSLETTER_FLOW_TITLE,
220
220
  status: "live",
221
- customersEnabled: false,
221
+ who: "visitor",
222
222
  completion: "lead",
223
223
  tags: [STANDARD_MAGNET_TAGS[NEWSLETTER_FLOW_SLUG]!],
224
224
  landingPage: NEWSLETTER_LANDING_PAGE,
@@ -242,7 +242,7 @@ export async function provisionDefaultWorkspace(
242
242
  kind: "diagnostic",
243
243
  title: TRIAL_FLOW_TITLE,
244
244
  status: "live",
245
- customersEnabled: false,
245
+ who: "visitor",
246
246
  completion: "lead",
247
247
  tags: [STANDARD_MAGNET_TAGS[TRIAL_FLOW_SLUG]!],
248
248
  landingPage: TRIAL_FLOW_LANDING_PAGE,
package/versions.json CHANGED
@@ -1,66 +1,66 @@
1
1
  {
2
- "@working-theory/admin-ui": "0.55.0",
3
- "@working-theory/aeo": "0.55.0",
4
- "@working-theory/agent-context": "0.55.0",
2
+ "@working-theory/admin-ui": "0.56.0",
3
+ "@working-theory/aeo": "0.56.0",
4
+ "@working-theory/agent-context": "0.56.0",
5
5
  "@working-theory/agent-contract": "0.1.0",
6
6
  "@working-theory/agent-memory": "0.0.0",
7
7
  "@working-theory/ai": "0.1.0",
8
- "@working-theory/analytics": "0.55.0",
9
- "@working-theory/api": "0.55.0",
10
- "@working-theory/api-client": "0.55.0",
8
+ "@working-theory/analytics": "0.56.0",
9
+ "@working-theory/api": "0.56.0",
10
+ "@working-theory/api-client": "0.56.0",
11
11
  "@working-theory/cda-filesystem": "0.0.0",
12
- "@working-theory/cli": "0.55.0",
13
- "@working-theory/common": "0.55.0",
12
+ "@working-theory/cli": "0.56.0",
13
+ "@working-theory/common": "0.56.0",
14
14
  "@working-theory/consent": "0.1.0",
15
- "@working-theory/content-adapters": "0.55.0",
16
- "@working-theory/content-projection": "0.55.0",
17
- "@working-theory/email": "0.55.0",
15
+ "@working-theory/content-adapters": "0.56.0",
16
+ "@working-theory/content-projection": "0.56.0",
17
+ "@working-theory/email": "0.56.0",
18
18
  "@working-theory/env": "0.0.0",
19
- "@working-theory/eslint-config": "0.55.0",
20
- "@working-theory/fumadocs-schema": "0.55.0",
21
- "@working-theory/ids": "0.19.0",
22
- "@working-theory/jsonld": "0.55.0",
23
- "@working-theory/license": "0.55.0",
24
- "@working-theory/mcp-server": "0.55.0",
25
- "@working-theory/metadata": "0.55.0",
26
- "@working-theory/observability": "0.55.0",
27
- "@working-theory/page-renderer": "0.55.0",
19
+ "@working-theory/eslint-config": "0.56.0",
20
+ "@working-theory/fumadocs-schema": "0.56.0",
21
+ "@working-theory/ids": "0.20.0",
22
+ "@working-theory/jsonld": "0.56.0",
23
+ "@working-theory/license": "0.56.0",
24
+ "@working-theory/mcp-server": "0.56.0",
25
+ "@working-theory/metadata": "0.56.0",
26
+ "@working-theory/observability": "0.56.0",
27
+ "@working-theory/page-renderer": "0.56.0",
28
28
  "@working-theory/plugin-mcp": "0.1.3",
29
- "@working-theory/schema": "0.55.0",
30
- "@working-theory/schema-pack-registry": "0.55.0",
31
- "@working-theory/schema-validator": "0.55.0",
32
- "@working-theory/sections-registry": "0.55.0",
33
- "@working-theory/site-config": "0.55.0",
34
- "@working-theory/spoke-checkout": "0.7.11",
35
- "@working-theory/stripe-client": "0.55.0",
36
- "@working-theory/templates": "0.55.0",
37
- "@working-theory/theme": "0.55.0",
38
- "@working-theory/ui": "0.55.0",
39
- "@working-theory/validation": "0.55.0",
40
- "@working-theory/web-i18n": "0.55.0",
41
- "@working-theory/admin": "0.10.1",
42
- "@working-theory/api-commerce": "0.1.9",
43
- "@working-theory/api-content-write": "0.5.1",
44
- "@working-theory/api-hub": "0.4.1",
45
- "@working-theory/api-multi-tenant": "0.2.4",
46
- "@working-theory/api-scheduling": "0.4.6",
47
- "@working-theory/api-signal-collectors": "0.3.2",
48
- "@working-theory/api-workspace": "0.5.1",
29
+ "@working-theory/schema": "0.56.0",
30
+ "@working-theory/schema-pack-registry": "0.56.0",
31
+ "@working-theory/schema-validator": "0.56.0",
32
+ "@working-theory/sections-registry": "0.56.0",
33
+ "@working-theory/site-config": "0.56.0",
34
+ "@working-theory/spoke-checkout": "0.7.12",
35
+ "@working-theory/stripe-client": "0.56.0",
36
+ "@working-theory/templates": "0.56.0",
37
+ "@working-theory/theme": "0.56.0",
38
+ "@working-theory/ui": "0.56.0",
39
+ "@working-theory/validation": "0.56.0",
40
+ "@working-theory/web-i18n": "0.56.0",
41
+ "@working-theory/admin": "0.11.0",
42
+ "@working-theory/api-commerce": "0.1.10",
43
+ "@working-theory/api-content-write": "0.6.0",
44
+ "@working-theory/api-hub": "0.4.2",
45
+ "@working-theory/api-multi-tenant": "0.2.5",
46
+ "@working-theory/api-scheduling": "0.4.7",
47
+ "@working-theory/api-signal-collectors": "0.4.0",
48
+ "@working-theory/api-workspace": "0.6.0",
49
49
  "@working-theory/billing-client": "0.0.0",
50
50
  "@working-theory/branding": "0.0.0",
51
51
  "@working-theory/license-client": "0.0.0",
52
52
  "@working-theory/license-manifest": "0.0.0",
53
- "@working-theory/mcp-scheduling": "0.3.8",
54
- "@working-theory/mcp-signal-collectors": "0.2.9",
55
- "@working-theory/mcp-workspace": "0.6.1",
53
+ "@working-theory/mcp-scheduling": "0.3.9",
54
+ "@working-theory/mcp-signal-collectors": "0.2.10",
55
+ "@working-theory/mcp-workspace": "0.6.2",
56
56
  "@working-theory/partner-session": "0.0.0",
57
57
  "@working-theory/preview-protocol": "0.0.0",
58
- "@working-theory/ui-commerce": "0.23.33",
59
- "@working-theory/ui-docs-blog": "0.23.33",
60
- "@working-theory/ui-scheduling": "0.25.7",
61
- "@working-theory/ui-signal-collectors": "0.27.11",
58
+ "@working-theory/ui-commerce": "0.23.34",
59
+ "@working-theory/ui-docs-blog": "0.23.34",
60
+ "@working-theory/ui-scheduling": "0.25.8",
61
+ "@working-theory/ui-signal-collectors": "0.28.0",
62
62
  "@working-theory/web": "0.0.0",
63
63
  "@working-theory/app-api": "1.0.0",
64
64
  "@working-theory/mcp": "0.0.0",
65
- "@working-theory/database": "0.55.0"
65
+ "@working-theory/database": "0.56.0"
66
66
  }
@@ -1,89 +0,0 @@
1
- "use client"; // Reads sessionStorage (a browser API) once on mount — a server page cannot.
2
-
3
- /**
4
- * AccountInviteLink — the /thanks page's "Track this in your account" link
5
- * (#8771 D6). The magnet walk writes one `sessionStorage` item,
6
- * `ailk:account-invite`, before it navigates here; this shows the link only
7
- * for the thank-you page that walk was headed to, and only if the stored href
8
- * is a same-site sign-in path. It reads the item once and removes it, so the
9
- * email in the href does not stay in the tab. A refresh shows no link (the
10
- * invite email still carries it).
11
- *
12
- * Base tree: it imports nothing from `flows/**`, so a build without the walk
13
- * still compiles this page.
14
- */
15
- import { Button, Container, Link } from "@working-theory/ui";
16
- import type { ReactElement } from "react";
17
- import { useEffect, useState } from "react";
18
-
19
- /** Must equal `ACCOUNT_INVITE_STORAGE_KEY` in the walk's `FlowStepperClient`. */
20
- const STORAGE_KEY = "ailk:account-invite";
21
- /** A reserved name that never resolves: only used to test an href stays on-site. */
22
- const PLACEHOLDER_ORIGIN = "https://placeholder.invalid";
23
- const HUB_SIGN_IN_PATH = "/api/hub-session/sign-in";
24
-
25
- interface Props {
26
- /** The route's `[slug]` — the hand-off must have been written for this page. */
27
- thanksSlug: string;
28
- /** `contact.trackInAccount`, read on the server. */
29
- label: string;
30
- }
31
-
32
- /** The stored href when it is a same-site sign-in path, else `null`. */
33
- function safeSignInHref(raw: unknown, thanks: unknown, thanksSlug: string): string | null {
34
- if (typeof raw !== "string" || thanks !== thanksSlug) return null;
35
- let url: URL;
36
- try {
37
- url = new URL(raw, PLACEHOLDER_ORIGIN);
38
- } catch {
39
- return null;
40
- }
41
- if (url.origin !== PLACEHOLDER_ORIGIN) return null;
42
- if (url.pathname !== HUB_SIGN_IN_PATH && !url.pathname.endsWith("/sign-in")) {
43
- return null;
44
- }
45
- return raw;
46
- }
47
-
48
- /** Read the hand-off once, remove it, and answer the link to show (or `null`). */
49
- function takeHandOff(thanksSlug: string): string | null {
50
- try {
51
- const stored = window.sessionStorage.getItem(STORAGE_KEY);
52
- if (stored === null) return null;
53
- window.sessionStorage.removeItem(STORAGE_KEY);
54
- const parsed: unknown = JSON.parse(stored);
55
- if (typeof parsed !== "object" || parsed === null) return null;
56
- const { thanks, href } = parsed as { thanks?: unknown; href?: unknown };
57
- return safeSignInHref(href, thanks, thanksSlug);
58
- } catch {
59
- return null;
60
- }
61
- }
62
-
63
- export function AccountInviteLink({ thanksSlug, label }: Props): ReactElement | null {
64
- const [href, setHref] = useState<string | null>(null);
65
-
66
- useEffect(() => {
67
- // Only ever sets the link: React's development double-mount runs this
68
- // twice, and the second run finds the item already removed.
69
- const found = takeHandOff(thanksSlug);
70
- if (found !== null) setHref(found);
71
- }, [thanksSlug]);
72
-
73
- if (href === null) return null;
74
- return (
75
- // Session Replay records the DOM, so the link (it carries the email) is
76
- // blocked from it; `Link` does not forward an undeclared data-* attribute.
77
- // The page shell sets no gutters, so the link sits in its own Container,
78
- // lined up with the thank-you content above it.
79
- <Container className="pb-section">
80
- <div data-sentry-block>
81
- <Button asChild variant="outline" className="min-h-11">
82
- <Link href={href} variant="bare" data-testid="thanks-account-invite">
83
- {label}
84
- </Link>
85
- </Button>
86
- </div>
87
- </Container>
88
- );
89
- }
@@ -1,104 +0,0 @@
1
- /**
2
- * #8771 AC-5 — /thanks shows only its own hand-off: the walk's sessionStorage
3
- * item, for this page's slug, when its href is a same-site sign-in path.
4
- */
5
- import { existsSync, readFileSync } from "node:fs";
6
- import { join } from "node:path";
7
-
8
- import { render, screen } from "@testing-library/react";
9
- import React, { StrictMode } from "react";
10
-
11
- import { AccountInviteLink } from "../AccountInviteLink";
12
-
13
- const KEY = "ailk:account-invite";
14
- const LABEL = "Track this in your account";
15
- const SITE_HREF = "/sign-in?next=%2Faccount&email=jane%40example.com";
16
- const HUB_HREF = "/api/hub-session/sign-in?next=%2Faccount&email=x%40y.example";
17
- const WALK = join(__dirname, "..", "..", "..", "flows", "[slug]", "FlowStepperClient.tsx");
18
- // A Free scaffold ships no magnet walk, and the scaffold matrix runs this suite
19
- // inside one (#8799), so the key check skips where the walk does not exist.
20
- const itWithWalk = existsSync(WALK) ? it : it.skip;
21
-
22
- function store(value: unknown): void {
23
- window.sessionStorage.setItem(KEY, typeof value === "string" ? value : JSON.stringify(value));
24
- }
25
-
26
- function show(slug = "flow-thanks") {
27
- return render(<AccountInviteLink thanksSlug={slug} label={LABEL} />);
28
- }
29
-
30
- beforeEach(() => window.sessionStorage.clear());
31
-
32
- describe("AccountInviteLink", () => {
33
- it("renders the stored link for its own slug, blocked from Replay, and removes the item", () => {
34
- store({ thanks: "flow-thanks", href: SITE_HREF });
35
-
36
- show();
37
-
38
- const link = screen.getByTestId("thanks-account-invite");
39
- expect(link).toHaveAttribute("href", SITE_HREF);
40
- expect(link).toHaveTextContent(LABEL);
41
- expect(link.closest("[data-sentry-block]")).not.toBeNull();
42
- expect(window.sessionStorage.getItem(KEY)).toBeNull();
43
- });
44
-
45
- it("renders a hub spoke's sign-in path under the matching slug", () => {
46
- store({ thanks: "flow-thanks", href: HUB_HREF });
47
- show();
48
- expect(screen.getByTestId("thanks-account-invite")).toHaveAttribute("href", HUB_HREF);
49
- });
50
-
51
- it("renders a locale-prefixed site sign-in path", () => {
52
- store({ thanks: "flow-thanks", href: "/fr/sign-in?email=a%40b.example" });
53
- show();
54
- expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
55
- });
56
-
57
- it.each([
58
- ["another slug", { thanks: "other", href: SITE_HREF }, "flow-thanks"],
59
- ["an off-site href", { thanks: "flow-thanks", href: "https://evil.example/sign-in" }, "flow-thanks"],
60
- ["a protocol-relative href", { thanks: "flow-thanks", href: "//evil.example/sign-in" }, "flow-thanks"],
61
- ["a non-sign-in path", { thanks: "flow-thanks", href: "/account" }, "flow-thanks"],
62
- ["a hub href under another thanks", { thanks: "flow-thanks-qualified", href: HUB_HREF }, "flow-thanks"],
63
- ["a value that is not JSON", "not json", "flow-thanks"],
64
- ["a JSON null", "null", "flow-thanks"],
65
- ])("renders nothing for %s", (_name, value, slug) => {
66
- store(value);
67
- show(slug);
68
- expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
69
- });
70
-
71
- it("renders nothing when sessionStorage throws", () => {
72
- const spy = jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
73
- throw new Error("denied");
74
- });
75
- show();
76
- expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
77
- spy.mockRestore();
78
- });
79
-
80
- it("shows the link once: a fresh mount after it (a refresh) renders nothing", () => {
81
- store({ thanks: "flow-thanks", href: SITE_HREF });
82
- const first = show();
83
- expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
84
- first.unmount();
85
-
86
- show();
87
- expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
88
- });
89
-
90
- it("keeps the link under React.StrictMode's double effect", () => {
91
- store({ thanks: "flow-thanks", href: SITE_HREF });
92
- render(
93
- <StrictMode>
94
- <AccountInviteLink thanksSlug="flow-thanks" label={LABEL} />
95
- </StrictMode>,
96
- );
97
- expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
98
- });
99
-
100
- itWithWalk("reads the same storage key the walk writes (the two constants are tied only by this test)", () => {
101
- const walk = readFileSync(WALK, "utf8");
102
- expect(walk).toContain(`ACCOUNT_INVITE_STORAGE_KEY = "${KEY}"`);
103
- });
104
- });