create-ailk 0.25.1 → 0.27.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 (75) hide show
  1. package/README.md +14 -5
  2. package/dist/lib/fetch-template-snapshot.d.ts +2 -1
  3. package/dist/lib/fetch-template-snapshot.js +6 -5
  4. package/dist/lib/module-license-gate.js +2 -3
  5. package/dist/lib/module-tier.d.ts +2 -8
  6. package/dist/lib/module-tier.js +13 -12
  7. package/dist/module-architecture.js +14 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +36 -2
  11. package/dist/template-record.d.ts +11 -3
  12. package/dist/template-record.js +20 -7
  13. package/dist/topology.js +23 -4
  14. package/dist/update.d.ts +1 -1
  15. package/dist/update.js +240 -20
  16. package/package.json +3 -3
  17. package/release-scope.json +1 -1
  18. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +8 -2
  19. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +2 -2
  20. package/site-templates/marketing-site/ailk-template.json +7 -1
  21. package/site-templates/marketing-site/provenance.json +7 -1
  22. package/site-templates/saas-product/ailk-template.json +2 -1
  23. package/site-templates/saas-product/provenance.json +1 -0
  24. package/templates/.env.example +22 -0
  25. package/templates/CLAUDE.md +20 -20
  26. package/templates/README.md +19 -19
  27. package/templates/apps/api/.env.example +28 -0
  28. package/templates/apps/api/CLAUDE.md +35 -9
  29. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  30. package/templates/apps/api/vercel.json +7 -1
  31. package/templates/apps/mcp/CLAUDE.md +27 -10
  32. package/templates/apps/web/.env.example +13 -0
  33. package/templates/apps/web/CLAUDE.md +34 -6
  34. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  35. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  36. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/next-path.test.ts +24 -1
  37. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +168 -8
  38. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +13 -0
  39. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +22 -39
  40. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  41. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  42. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +301 -0
  43. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  44. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +96 -18
  45. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  46. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  47. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  49. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  50. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  51. package/templates/apps/web/app/[locale]/layout.tsx +48 -13
  52. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  53. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  54. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +118 -0
  55. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +14 -0
  56. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +51 -0
  57. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +13 -0
  58. package/templates/apps/web/auth.ts +8 -4
  59. package/templates/apps/web/i18n/request.ts +49 -1
  60. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  61. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  62. package/templates/apps/web/lib/__tests__/relay.test.ts +289 -1
  63. package/templates/apps/web/lib/auth-email.ts +102 -20
  64. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +92 -23
  65. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +97 -70
  66. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  67. package/templates/apps/web/lib/relay.ts +116 -1
  68. package/templates/apps/web/lib/sign-up-agreements.ts +302 -28
  69. package/templates/apps/web/lib/site-contributions.ts +18 -0
  70. package/templates/apps/web/messages/en.json +271 -20
  71. package/templates/apps/web/messages/site/en.json +1 -0
  72. package/templates/apps/web/middleware.ts +8 -1
  73. package/templates/apps/web/next.config.mjs +9 -1
  74. package/templates/apps/web/package.json +2 -1
  75. package/versions.json +48 -48
@@ -20,18 +20,23 @@
20
20
  * current pathname (via `usePathname()`) so a successful social sign-in
21
21
  * lands back on the exact dashboard page the visitor requested.
22
22
  *
23
- * Email sign-in link (#7435 § D9): when the layout passes `emailLink`, the
24
- * gate renders `/sign-in`'s own `SignInCard` — the same providers plus "Email
25
- * me a sign-in link" — in place of `AuthPanel`, whose email + password form
26
- * has no submit wiring and would otherwise sit inert beside the new field.
27
- * Without the prop every render is what it was before.
23
+ * Email sign-in link (#7435 § D9): when the layout passes `card` with
24
+ * `emailLink`, the gate renders `/sign-in`'s own `SignInCard` — the same
25
+ * providers plus "Send me a magic link" — in place of `AuthPanel`, whose
26
+ * email + password form has no submit wiring and would otherwise sit inert
27
+ * beside the new field. Without the prop every render is what it was before.
28
+ *
29
+ * The one card (#9662): on the member `/account` gate the layout passes
30
+ * `card` whenever a method is on, with the site's mark, the "Don't have an
31
+ * account? Sign up" line and the Terms + Privacy acknowledgement, so the gate
32
+ * renders the same card `/sign-in` does.
28
33
  */
29
34
 
30
35
  import { EmptyState, Link } from "@working-theory/ui/primitives";
31
36
  import { AuthPanel, type AuthPanelProvider } from "@working-theory/ui/sections";
32
37
  import { createAuthClient } from "better-auth/react";
33
38
  import { usePathname } from "next/navigation";
34
- import type { ReactElement } from "react";
39
+ import type { ReactElement, ReactNode } from "react";
35
40
 
36
41
  import { SignInCard } from "../(auth)/sign-in/sign-in-card";
37
42
  import type { SignInCardText } from "../(auth)/sign-in/sign-in-text";
@@ -52,17 +57,25 @@ export interface SignInGateProps {
52
57
  /** The active social providers, resolved server-side by the layout (#7419). */
53
58
  providers: AuthPanelProvider[];
54
59
  /**
55
- * Present iff the email sign-in link is on (#7435) — the sign-in card's
56
- * copy, resolved server-side by the layout.
60
+ * Renders `SignInCard` in place of `AuthPanel` — present when the email
61
+ * sign-in link is on (#7435) or on the member gate (#9662). The card's copy,
62
+ * mark, prospect line and footnote are resolved server-side by the layout.
57
63
  */
58
- emailLink?: { text: SignInCardText };
64
+ card?: {
65
+ text: SignInCardText;
66
+ /** Offer "Send me a magic link" — the email sign-in link is on. */
67
+ emailLink: boolean;
68
+ logo?: ReactNode;
69
+ prospect?: { prompt: string; linkLabel: string; href: string };
70
+ footnote?: ReactNode;
71
+ };
59
72
  }
60
73
 
61
74
  export function SignInGate({
62
75
  labels,
63
76
  signInPath,
64
77
  providers,
65
- emailLink,
78
+ card,
66
79
  }: SignInGateProps): ReactElement {
67
80
  const pathname = usePathname();
68
81
  const signInHref = `${signInPath}?next=${encodeURIComponent(pathname)}`;
@@ -79,25 +92,35 @@ export function SignInGate({
79
92
  </Link>
80
93
  );
81
94
 
82
- if (emailLink) {
83
- const { text } = emailLink;
95
+ if (card) {
96
+ const { text } = card;
84
97
  return (
85
98
  <div
86
99
  className="flex min-h-screen flex-col items-center justify-center gap-4 py-section"
87
100
  data-testid="sign-in-gate"
88
101
  >
89
- <SignInCard
90
- providers={providers}
91
- method="sso"
92
- next={pathname}
93
- labels={text.labels}
94
- notConfiguredSubhead={text.notConfiguredSubhead}
95
- errorLabels={text.errorLabels}
96
- emailLink
97
- errorCallbackURL={signInHref}
98
- signInHref={signInHref}
99
- emailLinkLabels={text.emailLinkLabels}
100
- />
102
+ {/* AuthShell's 400px column: the cap sits on the wrapper, never on the card (AuthShell.tsx). */}
103
+ <div className="w-full max-w-100">
104
+ <SignInCard
105
+ providers={providers}
106
+ method="sso"
107
+ next={pathname}
108
+ labels={text.labels}
109
+ notConfiguredSubhead={text.notConfiguredSubhead}
110
+ errorLabels={text.errorLabels}
111
+ emailLink={card.emailLink}
112
+ errorCallbackURL={signInHref}
113
+ signInHref={signInHref}
114
+ emailLinkLabels={text.emailLinkLabels}
115
+ {...(card.logo !== undefined ? { logo: card.logo } : {})}
116
+ {...(card.prospect !== undefined
117
+ ? { prospect: card.prospect }
118
+ : {})}
119
+ {...(card.footnote !== undefined
120
+ ? { footnote: card.footnote }
121
+ : {})}
122
+ />
123
+ </div>
101
124
  {signInLink}
102
125
  </div>
103
126
  );
@@ -11,9 +11,11 @@ import { readLocales } from "@working-theory/web-i18n";
11
11
  import { getTranslations } from "next-intl/server";
12
12
  import type { ReactElement } from "react";
13
13
 
14
+ import { legalFootnote } from "../(auth)/legal-footnote";
14
15
  import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
15
16
  import { getActiveProviderIds } from "../../../lib/auth-env";
16
17
  import { isAccountPath } from "../../../lib/auth-routes";
18
+ import { siteLogo } from "../../../lib/site-logo";
17
19
 
18
20
  import { SignInGate } from "./sign-in-gate";
19
21
 
@@ -39,9 +41,7 @@ export async function SignedOutGate({
39
41
  namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
40
42
  });
41
43
  const active = getActiveProviderIds();
42
- const emailLink = active.includes("magic-link")
43
- ? { text: await resolveSignInCardText(locale) }
44
- : undefined;
44
+ const emailLink = active.includes("magic-link");
45
45
  const providers = active.filter((id): id is AuthPanelProvider =>
46
46
  (SUPPORTED_PROVIDERS as readonly string[]).includes(id),
47
47
  );
@@ -75,6 +75,25 @@ export async function SignedOutGate({
75
75
  );
76
76
  }
77
77
 
78
+ // The sign-in card: with the email link on, and on the member gate, which
79
+ // also wears the one card's frame — the mark, the toggle line to /sign-up
80
+ // and the Terms + Privacy acknowledgement (#9662).
81
+ const text =
82
+ emailLink || isAccount ? await resolveSignInCardText(locale) : undefined;
83
+ const card = text
84
+ ? {
85
+ text,
86
+ emailLink,
87
+ ...(isAccount
88
+ ? {
89
+ logo: siteLogo(),
90
+ prospect: { ...text.prospect, href: localeHref(locale, "/sign-up") },
91
+ footnote: legalFootnote(locale, text.acknowledgement),
92
+ }
93
+ : {}),
94
+ }
95
+ : undefined;
96
+
78
97
  return (
79
98
  <SignInGate
80
99
  labels={{
@@ -84,7 +103,7 @@ export async function SignedOutGate({
84
103
  }}
85
104
  signInPath={signInPath}
86
105
  providers={providers}
87
- {...(emailLink ? { emailLink } : {})}
106
+ {...(card ? { card } : {})}
88
107
  />
89
108
  );
90
109
  }
@@ -48,6 +48,7 @@ import {
48
48
  getStealthRobots,
49
49
  readLocales,
50
50
  } from "@working-theory/web-i18n";
51
+ import { BotIdClient } from "botid/client";
51
52
  import type { Metadata, Viewport } from "next";
52
53
  import { cookies, headers } from "next/headers";
53
54
  import { NextIntlClientProvider } from "next-intl";
@@ -77,6 +78,7 @@ import { SCHEDULING_PAGES } from "../../lib/modules.generated";
77
78
  import { footerColumns, navItems } from "../../lib/nav-config";
78
79
  import { getOgImageUrl } from "../../lib/og";
79
80
  import { backLinkHref } from "../../lib/profile-back-link";
81
+ import { BOT_CHECKED_ROUTES, bookingBotCheckOn } from "../../lib/relay";
80
82
  import {
81
83
  COPYRIGHT_HOLDER,
82
84
  SITE_DESCRIPTION,
@@ -704,6 +706,19 @@ export default async function LocaleLayout({
704
706
  dangerouslySetInnerHTML={{ __html: FLASH_PREVENTION_SCRIPT }}
705
707
  />
706
708
  )}
709
+ {/* #9889 D5 — BotID's browser half: one inline script, nothing
710
+ visible, protecting exactly the pairs the relay checks. Mounted
711
+ only where the server check runs, so a site with it off is
712
+ byte-identical and one off Vercel never loads a challenge it
713
+ cannot complete. */}
714
+ {bookingBotCheckOn() ? (
715
+ <BotIdClient
716
+ protect={BOT_CHECKED_ROUTES.map((r) => ({
717
+ path: r.path,
718
+ method: r.method,
719
+ }))}
720
+ />
721
+ ) : null}
707
722
  {/* Inline CSS-variable declarations for the active theme mode */}
708
723
  <style
709
724
  data-ailk="theme-vars"
@@ -952,7 +967,9 @@ export default async function LocaleLayout({
952
967
  * cookie read — same #5557 hydration-safety property, no gate
953
968
  * needed. This also means the banner never blocks the page: it
954
969
  * reserves its own space (D1) and carries a close-without-choosing
955
- * control (D2) instead of a hard server-side hide.
970
+ * control (D2) instead of a hard server-side hide. The standard
971
+ * chrome's compact card has no close control (#9656 D5): it is a
972
+ * non-modal corner card and Decline All is one tap.
956
973
  */}
957
974
  {isAccountRoute ? null : (
958
975
  <ConsentBanner
@@ -961,18 +978,36 @@ export default async function LocaleLayout({
961
978
  consentCookieName={CONSENT_COOKIE_NAME}
962
979
  consentDecided={consentDecided}
963
980
  title={tConsent("title")}
964
- message={
965
- <>
966
- {consentBannerStatement}{" "}
967
- <Link
968
- href={localeHref(locale, "/privacy")}
969
- variant="unstyled"
970
- className="font-semibold text-current underline underline-offset-4"
971
- >
972
- {tConsent("policyLinkLabel")}
973
- </Link>
974
- </>
975
- }
981
+ // #9656 D7 — the standard chrome mounts the compact floating
982
+ // card on every width, its Privacy Policy link on the button
983
+ // row; the minimal chrome keeps its hairline band (#7742 D5),
984
+ // the statement with the link inline.
985
+ {...(isMinimalChrome
986
+ ? {
987
+ message: (
988
+ <>
989
+ {consentBannerStatement}{" "}
990
+ <Link
991
+ href={localeHref(locale, "/privacy")}
992
+ variant="unstyled"
993
+ className="font-semibold text-current underline underline-offset-4"
994
+ >
995
+ {tConsent("policyLinkLabel")}
996
+ </Link>
997
+ </>
998
+ ),
999
+ }
1000
+ : {
1001
+ message: consentBannerStatement,
1002
+ presentation: "floating" as const,
1003
+ corner: "bottom-left" as const,
1004
+ consentAppearance: "compact" as const,
1005
+ mobileConsent: "inherit" as const,
1006
+ consentPolicyLink: {
1007
+ label: tConsent("policyLinkLabel"),
1008
+ href: "/privacy",
1009
+ },
1010
+ })}
976
1011
  dismissible={false}
977
1012
  dismissLabel={tConsent("dismissLabel")}
978
1013
  acceptLabel={tConsent("acceptLabel")}
@@ -0,0 +1,128 @@
1
+ // oss: false
2
+ "use client"; // Reads sessionStorage (a browser API) once on mount — a server page cannot.
3
+
4
+ /**
5
+ * BookingHandOff — `/meet/confirmed`'s booked time and Slack next step
6
+ * (#9853 D10). The booker writes one `sessionStorage` item before it
7
+ * navigates here (`BOOKING_CONFIRMATION_STORAGE_KEY`); this reads it once and
8
+ * removes it, the `/thanks` page's `AccountInviteLink` pattern, so the page's
9
+ * address carries nothing about the booking and a later visit shows nothing.
10
+ *
11
+ * It renders only an item written for this page (`to`'s path, without its
12
+ * query or hash, is `/meet/confirmed` or `/<locale>/meet/confirmed`) in the last ten
13
+ * minutes. The time is formatted in the zone Cal.com recorded, with the zone
14
+ * named, so the page and the Slack welcome name the same one; a zone `Intl`
15
+ * does not know renders nothing. Every string is a prop the server page read
16
+ * from the `schedule` namespace.
17
+ */
18
+ import { Container, Heading, Text } from "@working-theory/ui";
19
+ import {
20
+ BOOKING_CONFIRMATION_STORAGE_KEY,
21
+ BookingConfirmationHandOffSchema,
22
+ } from "@working-theory/validation";
23
+ import type { ReactElement } from "react";
24
+ import { useEffect, useState } from "react";
25
+
26
+ /** A reserved name that never resolves: only used to read the path of `to`. */
27
+ const PLACEHOLDER_ORIGIN = "https://placeholder.invalid";
28
+ const CONFIRMED_PATH = "/meet/confirmed";
29
+ const MAX_AGE_MS = 10 * 60_000;
30
+
31
+ interface Props {
32
+ locale: string;
33
+ /** `schedule.confirmed.bookedTimeHeading`. */
34
+ bookedTimeHeading: string;
35
+ /** `schedule.confirmed.slackHeadline`. */
36
+ slackHeadline: string;
37
+ /** `schedule.confirmed.slackBody`. */
38
+ slackBody: string;
39
+ }
40
+
41
+ interface Shown {
42
+ time: string;
43
+ slackOptIn: boolean;
44
+ }
45
+
46
+ /** Whether `to` names this page, with or without this page's `/<locale>`. */
47
+ function namesThisPage(to: string, locale: string): boolean {
48
+ let url: URL;
49
+ try {
50
+ url = new URL(to, PLACEHOLDER_ORIGIN);
51
+ } catch {
52
+ return false;
53
+ }
54
+ if (url.origin !== PLACEHOLDER_ORIGIN) return false;
55
+ return (
56
+ url.pathname === CONFIRMED_PATH ||
57
+ url.pathname === `/${locale}${CONFIRMED_PATH}`
58
+ );
59
+ }
60
+
61
+ /** Read the hand-off once, remove it, and answer what to show (or `null`). */
62
+ function takeHandOff(locale: string): Shown | null {
63
+ try {
64
+ const stored = window.sessionStorage.getItem(
65
+ BOOKING_CONFIRMATION_STORAGE_KEY,
66
+ );
67
+ if (stored === null) return null;
68
+ window.sessionStorage.removeItem(BOOKING_CONFIRMATION_STORAGE_KEY);
69
+ const parsed = BookingConfirmationHandOffSchema.safeParse(
70
+ JSON.parse(stored),
71
+ );
72
+ if (!parsed.success) return null;
73
+ const { to, savedAt, start, timeZone, slackOptIn } = parsed.data;
74
+ const age = Date.now() - savedAt;
75
+ if (!namesThisPage(to, locale) || age < 0 || age >= MAX_AGE_MS) return null;
76
+ const time = new Intl.DateTimeFormat(locale, {
77
+ weekday: "long",
78
+ month: "long",
79
+ day: "numeric",
80
+ hour: "numeric",
81
+ minute: "2-digit",
82
+ timeZone,
83
+ timeZoneName: "longGeneric",
84
+ }).format(new Date(start));
85
+ return { time, slackOptIn };
86
+ } catch {
87
+ // Unparseable JSON, refused storage, or a zone `Intl` does not know.
88
+ return null;
89
+ }
90
+ }
91
+
92
+ export function BookingHandOff({
93
+ locale,
94
+ bookedTimeHeading,
95
+ slackHeadline,
96
+ slackBody,
97
+ }: Props): ReactElement | null {
98
+ const [shown, setShown] = useState<Shown | null>(null);
99
+
100
+ useEffect(() => {
101
+ // Only ever sets: React's development double-mount runs this twice, and
102
+ // the second run finds the item already removed.
103
+ const found = takeHandOff(locale);
104
+ if (found !== null) setShown(found);
105
+ }, [locale]);
106
+
107
+ if (shown === null) return null;
108
+ return (
109
+ <Container className="pt-block-loose">
110
+ <div role="status" data-testid="booking-hand-off">
111
+ <Heading level={2} visual={4}>
112
+ {bookedTimeHeading}
113
+ </Heading>
114
+ <Text className="mt-2">{shown.time}</Text>
115
+ {shown.slackOptIn && (
116
+ <>
117
+ <Heading level={2} visual={4} className="mt-block-tight">
118
+ {slackHeadline}
119
+ </Heading>
120
+ <Text tone="muted" className="mt-2">
121
+ {slackBody}
122
+ </Text>
123
+ </>
124
+ )}
125
+ </div>
126
+ </Container>
127
+ );
128
+ }
@@ -0,0 +1,126 @@
1
+ // oss: false
2
+ /**
3
+ * Tests for `/meet/confirmed`'s booking hand-off (#9853 D9, D10; AC-7).
4
+ *
5
+ * The booker hands this page the booked time, its zone and the Slack opt-in
6
+ * through one `sessionStorage` item, never the address. The component reads
7
+ * it once and removes it, and shows it only when it was written for this page
8
+ * in the last ten minutes, formatted in the zone Cal.com recorded — the
9
+ * prospect's, not the viewer's. Anything else renders nothing.
10
+ */
11
+ import React from "react";
12
+ import { act, render, screen } from "@testing-library/react";
13
+
14
+ import { BookingHandOff } from "../BookingHandOff";
15
+
16
+ const STORAGE_KEY = "ailk:booking-confirmation";
17
+ const PROPS = {
18
+ locale: "en",
19
+ bookedTimeHeading: "copy:confirmed.bookedTimeHeading",
20
+ slackHeadline: "copy:confirmed.slackHeadline",
21
+ slackBody: "copy:confirmed.slackBody",
22
+ };
23
+
24
+ function handOff(overrides: Record<string, unknown> = {}) {
25
+ return {
26
+ to: "/meet/confirmed",
27
+ savedAt: Date.now(),
28
+ start: "2026-10-30T20:00:00.000Z",
29
+ timeZone: "America/Denver",
30
+ slackOptIn: true,
31
+ ...overrides,
32
+ };
33
+ }
34
+
35
+ function store(value: unknown): void {
36
+ window.sessionStorage.setItem(
37
+ STORAGE_KEY,
38
+ typeof value === "string" ? value : JSON.stringify(value),
39
+ );
40
+ }
41
+
42
+ async function renderHandOff() {
43
+ let utils!: ReturnType<typeof render>;
44
+ await act(async () => {
45
+ utils = render(<BookingHandOff {...PROPS} />);
46
+ });
47
+ return utils;
48
+ }
49
+
50
+ beforeEach(() => {
51
+ window.sessionStorage.clear();
52
+ window.history.replaceState(null, "", "/en/meet/confirmed");
53
+ });
54
+
55
+ describe("<BookingHandOff/> (#9853 AC-7)", () => {
56
+ it("shows the booked time in the prospect's zone, named, and the Slack step; then forgets it", async () => {
57
+ store(handOff());
58
+ const { container } = await renderHandOff();
59
+
60
+ expect(screen.getByText(PROPS.bookedTimeHeading)).toBeInTheDocument();
61
+ expect(container.textContent).toContain("2:00");
62
+ expect(container.textContent).toContain("Mountain Time");
63
+ expect(screen.getByText(PROPS.slackHeadline)).toBeInTheDocument();
64
+ expect(screen.getByText(PROPS.slackBody)).toBeInTheDocument();
65
+ expect(screen.getByRole("status")).toBeInTheDocument();
66
+ expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
67
+ });
68
+
69
+ it("accepts a destination written with a locale prefix, a query or a hash", async () => {
70
+ store(handOff({ to: "/en/meet/confirmed?x=1#top" }));
71
+ const { container } = await renderHandOff();
72
+ expect(container.textContent).toContain("Mountain Time");
73
+ });
74
+
75
+ it("shows no Slack text when the box was not ticked", async () => {
76
+ store(handOff({ slackOptIn: false }));
77
+ const { container } = await renderHandOff();
78
+ expect(container.textContent).toContain("Mountain Time");
79
+ expect(screen.queryByText(PROPS.slackHeadline)).toBeNull();
80
+ expect(screen.queryByText(PROPS.slackBody)).toBeNull();
81
+ });
82
+
83
+ it.each<[string, () => void]>([
84
+ ["no item", () => undefined],
85
+ ["an unparseable item", () => store("{not json")],
86
+ [
87
+ "an item with an extra email key",
88
+ () => store(handOff({ email: "ada@example.com" })),
89
+ ],
90
+ [
91
+ "an item for another page",
92
+ () => store(handOff({ to: "/before-your-call" })),
93
+ ],
94
+ [
95
+ "an item eleven minutes old",
96
+ () => store(handOff({ savedAt: Date.now() - 11 * 60_000 })),
97
+ ],
98
+ [
99
+ "a zone Intl does not know",
100
+ () => store(handOff({ timeZone: "Mars/Olympus" })),
101
+ ],
102
+ ])("renders nothing, and removes the item, for %s", async (_label, seed) => {
103
+ seed();
104
+ const { container } = await renderHandOff();
105
+ expect(container).toBeEmptyDOMElement();
106
+ expect(window.sessionStorage.getItem(STORAGE_KEY)).toBeNull();
107
+ });
108
+
109
+ it("shows nothing on a later visit", async () => {
110
+ store(handOff());
111
+ const first = await renderHandOff();
112
+ first.unmount();
113
+ const { container } = await renderHandOff();
114
+ expect(container).toBeEmptyDOMElement();
115
+ });
116
+
117
+ it.each([
118
+ "/fr/meet/confirmed",
119
+ "/anything/meet/confirmed",
120
+ "//evil.example/meet/confirmed",
121
+ ])("renders nothing for a destination of %s", async (to) => {
122
+ store(handOff({ to }));
123
+ const { container } = await renderHandOff();
124
+ expect(container).toBeEmptyDOMElement();
125
+ });
126
+ });
@@ -14,6 +14,10 @@
14
14
  * #8757 D9 (AC-6): an optional `login_hint` reaches the sign-in page as
15
15
  * `email` on the no-session branch only; it never enters the continuation ref
16
16
  * or the code redirect, and a repeated one is a 400 like any repeated key.
17
+ *
18
+ * #9912 D3 (AC-1): a single `prompt=create` sends a visitor with no session to
19
+ * `/sign-up` with the same continuation; any other value gives `/sign-in`, a
20
+ * session ignores it, and it never enters the ref or the code request.
17
21
  */
18
22
 
19
23
  // --- Mocks set up BEFORE imports ---
@@ -459,3 +463,117 @@ describe("the login_hint (#8757 D9)", () => {
459
463
  );
460
464
  });
461
465
  });
466
+
467
+ describe("the sign-up hint, prompt=create (#9912 D3)", () => {
468
+ /** The `next` value of the 303, and the ref inside it. */
469
+ function continuationOf(res: Response): { next: string; ref: string } {
470
+ const location = res.headers.get("location") ?? "";
471
+ const next = new URL(location, HUB).searchParams.get("next") ?? "";
472
+ return { next, ref: next.slice(`${CONTINUE}/`.length) };
473
+ }
474
+
475
+ it("no session + prompt=create: 303 to /sign-up?next=<continue path>; the ref opens to the same six-key query as without the prompt", async () => {
476
+ mockGetSession.mockResolvedValue(null);
477
+ const canonical = query();
478
+
479
+ const res = await GET(request(`${canonical}&prompt=create`));
480
+ const plain = await GET(request(canonical));
481
+
482
+ expect(res.status).toBe(303);
483
+ const location = new URL(res.headers.get("location") ?? "", HUB);
484
+ expect(location.pathname).toBe("/sign-up");
485
+ expect([...location.searchParams.keys()]).toEqual(["next"]);
486
+ const { next, ref } = continuationOf(res);
487
+ expect(res.headers.get("location")).toBe(
488
+ `/sign-up?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
489
+ );
490
+ // The sign-up page keeps it, as the sign-in page does.
491
+ expect(resolveNextPath(next, "/account")).toBe(next);
492
+ const opened = openContinuation(ref, mockSecret);
493
+ expect(opened).toBe(canonical);
494
+ expect(opened).toBe(
495
+ openContinuation(continuationOf(plain).ref, mockSecret),
496
+ );
497
+ expect(opened).not.toContain("prompt");
498
+ expect(res.headers.get("cache-control")).toBe("no-store");
499
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
500
+ expect(mockCreateCode).not.toHaveBeenCalled();
501
+ });
502
+
503
+ it("no session + prompt=create + a valid login_hint: /sign-up carries no email", async () => {
504
+ mockGetSession.mockResolvedValue(null);
505
+
506
+ const res = await GET(
507
+ request(`${query()}&prompt=create&login_hint=a%40b.example`),
508
+ );
509
+
510
+ expect(res.status).toBe(303);
511
+ const location = new URL(res.headers.get("location") ?? "", HUB);
512
+ expect(location.pathname).toBe("/sign-up");
513
+ expect(location.searchParams.has("email")).toBe(false);
514
+ expect(res.headers.get("location")).not.toContain("b.example");
515
+ });
516
+
517
+ it.each([
518
+ ["prompt=login", "login"],
519
+ ["prompt=none", "none"],
520
+ ["an empty prompt", ""],
521
+ ["a cased prompt", "Create"],
522
+ ])("no session + %s: today's /sign-in", async (_name, prompt) => {
523
+ mockGetSession.mockResolvedValue(null);
524
+ const canonical = query();
525
+
526
+ const res = await GET(
527
+ request(`${canonical}&prompt=${encodeURIComponent(prompt)}`),
528
+ );
529
+
530
+ expect(res.status).toBe(303);
531
+ const { ref } = continuationOf(res);
532
+ expect(res.headers.get("location")).toBe(
533
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
534
+ );
535
+ expect(openContinuation(ref, mockSecret)).toBe(canonical);
536
+ });
537
+
538
+ it.each([
539
+ ["no session", null],
540
+ ["a session", { user: { id: "u1" } }],
541
+ ])(
542
+ "a repeated prompt is a 400 invalid_request (%s)",
543
+ async (_name, session) => {
544
+ mockGetSession.mockResolvedValue(session);
545
+
546
+ const res = await GET(request(`${query()}&prompt=create&prompt=create`));
547
+
548
+ expect(res.status).toBe(400);
549
+ expect(res.headers.get("location")).toBeNull();
550
+ expect(await res.json()).toEqual({ error: "invalid_request" });
551
+ expect(mockCreateCode).not.toHaveBeenCalled();
552
+ expect(mockGetSession).not.toHaveBeenCalled();
553
+ },
554
+ );
555
+
556
+ it("with a session: prompt=create is ignored — the same code redirect and the same API call", async () => {
557
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
558
+ mockCreateCode.mockResolvedValue({
559
+ ok: true,
560
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
561
+ });
562
+
563
+ const withPrompt = await GET(
564
+ request(`${query()}&prompt=create`, {
565
+ cookie: "better-auth.session_token=tok",
566
+ }),
567
+ );
568
+ const without = await GET(
569
+ request(query(), { cookie: "better-auth.session_token=tok" }),
570
+ );
571
+
572
+ expect(withPrompt.status).toBe(303);
573
+ const location = withPrompt.headers.get("location") ?? "";
574
+ expect(location).toBe(without.headers.get("location"));
575
+ expect(location).toBe(`${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`);
576
+ expect(mockCreateCode).toHaveBeenCalledTimes(2);
577
+ expect(mockCreateCode.mock.calls[0]).toEqual(mockCreateCode.mock.calls[1]);
578
+ });
579
+ });