create-ailk 0.25.1 → 0.26.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 (60) hide show
  1. package/README.md +12 -3
  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 +12 -2
  8. package/dist/starter-grid.d.ts +6 -6
  9. package/dist/starter-grid.js +12 -5
  10. package/dist/surfaces.js +29 -0
  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 +188 -18
  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 +15 -0
  25. package/templates/apps/api/.env.example +21 -0
  26. package/templates/apps/api/CLAUDE.md +18 -8
  27. package/templates/apps/api/src/lib/__mocks__/prisma-client.js +16 -0
  28. package/templates/apps/mcp/CLAUDE.md +27 -10
  29. package/templates/apps/web/CLAUDE.md +22 -1
  30. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +43 -4
  31. package/templates/apps/web/app/[locale]/(auth)/legal-footnote.tsx +35 -0
  32. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +117 -8
  33. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +14 -37
  34. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +6 -6
  35. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +20 -35
  36. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/page.test.tsx +108 -0
  37. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +111 -5
  38. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +78 -16
  39. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +132 -12
  40. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +53 -1
  41. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +38 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +4 -2
  43. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +47 -24
  44. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +23 -4
  45. package/templates/apps/web/app/[locale]/layout.tsx +33 -13
  46. package/templates/apps/web/app/[locale]/meet/confirmed/BookingHandOff.tsx +128 -0
  47. package/templates/apps/web/app/[locale]/meet/confirmed/__tests__/BookingHandOff.test.tsx +126 -0
  48. package/templates/apps/web/auth.ts +8 -4
  49. package/templates/apps/web/i18n/request.ts +49 -1
  50. package/templates/apps/web/lib/__tests__/account-config.test.ts +7 -0
  51. package/templates/apps/web/lib/__tests__/auth-email.test.ts +276 -0
  52. package/templates/apps/web/lib/auth-email.ts +102 -20
  53. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +72 -23
  54. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +94 -70
  55. package/templates/apps/web/lib/prisma-for-auth.ts +5 -2
  56. package/templates/apps/web/lib/sign-up-agreements.ts +291 -28
  57. package/templates/apps/web/lib/site-contributions.ts +18 -0
  58. package/templates/apps/web/messages/en.json +186 -20
  59. package/templates/apps/web/messages/site/en.json +1 -0
  60. package/versions.json +48 -48
@@ -17,11 +17,13 @@ import messages from "../../../../../messages/en.json";
17
17
 
18
18
  const mockSignUpEmail = jest.fn();
19
19
  const mockSignInSocial = jest.fn();
20
+ const mockFetch = jest.fn();
20
21
  jest.mock("better-auth/react", () => ({
21
22
  __esModule: true,
22
23
  createAuthClient: () => ({
23
24
  signUp: { email: (...args: unknown[]) => mockSignUpEmail(...args) },
24
25
  signIn: { social: (...args: unknown[]) => mockSignInSocial(...args) },
26
+ $fetch: (...args: unknown[]) => mockFetch(...args),
25
27
  }),
26
28
  }));
27
29
 
@@ -38,13 +40,11 @@ function props(overrides: Partial<SignUpCardProps> = {}): SignUpCardProps {
38
40
  providers: ["google"],
39
41
  method: "password",
40
42
  next: "/account",
41
- signInHref: "/sign-in",
43
+ prospect: { prompt: "Already have an account?", linkLabel: "Sign in", href: "/sign-in" },
44
+ footnote: <span>You acknowledge our terms.</span>,
42
45
  labels: {
43
- signUpHeading: "Create your account",
44
- signUpSubhead: "",
46
+ signUpHeading: "Sign up",
45
47
  signUpSubmit: "Sign up",
46
- signUpFooterPrompt: "Already have an account?",
47
- signUpFooterLink: "Sign in",
48
48
  ssoPrefix: "Continue with",
49
49
  google: "Google",
50
50
  github: "GitHub",
@@ -201,3 +201,109 @@ describe("provider buttons are gated too (AC-3)", () => {
201
201
  expect(document.cookie).toBe("");
202
202
  });
203
203
  });
204
+
205
+ describe("the magic-link method (#9667 AC-3)", () => {
206
+ const linkProps = (overrides: Partial<SignUpCardProps> = {}) =>
207
+ props({
208
+ method: "magic-link",
209
+ labels: {
210
+ ...props().labels,
211
+ magicLinkSubmit: messages.auth.magicLinkSubmit,
212
+ emailLinkDivider: messages.auth.orContinue,
213
+ },
214
+ errorCallbackURL: "/sign-up",
215
+ linkLabels: {
216
+ sentHeading: messages.auth.sentHeading,
217
+ sentSubhead: messages.auth.sentSubhead,
218
+ sentBody: messages.auth.signUpLinkSentBody,
219
+ back: messages.auth.signUpLinkBack,
220
+ failed: messages.auth.signUpLinkFailed,
221
+ rateLimited: messages.auth.signUpLinkRateLimited,
222
+ },
223
+ ...overrides,
224
+ });
225
+
226
+ async function requestLink(): Promise<void> {
227
+ const user = userEvent.setup();
228
+ await user.type(screen.getByTestId("auth-email"), "ada@example.com");
229
+ await user.click(screen.getByTestId("auth-submit"));
230
+ }
231
+
232
+ beforeEach(() => {
233
+ mockFetch.mockReset().mockResolvedValue({ data: { status: true }, error: null });
234
+ });
235
+
236
+ it("renders one Email field, Send me a magic link, no password, the agreements and the providers", () => {
237
+ render(<SignUpCard {...linkProps()} />);
238
+ expect(screen.getByTestId("auth-submit")).toHaveTextContent(messages.auth.magicLinkSubmit);
239
+ expect(screen.queryByTestId("auth-password")).toBeNull();
240
+ expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
241
+ expect(screen.getByTestId("auth-divider")).toHaveTextContent(messages.auth.orContinue);
242
+ expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
243
+ });
244
+
245
+ it("does nothing while the agreement is unticked", async () => {
246
+ render(<SignUpCard {...linkProps()} />);
247
+ await requestLink();
248
+ expect(mockFetch).not.toHaveBeenCalled();
249
+ });
250
+
251
+ it("once ticked, POSTs /sign-in/magic-link once with the agreements in metadata, then shows the sent card", async () => {
252
+ render(<SignUpCard {...linkProps()} />);
253
+ await tick("account_terms");
254
+ await requestLink();
255
+ expect(mockFetch).toHaveBeenCalledTimes(1);
256
+ expect(mockFetch).toHaveBeenCalledWith("/sign-in/magic-link", {
257
+ method: "POST",
258
+ body: {
259
+ email: "ada@example.com",
260
+ name: "ada@example.com",
261
+ callbackURL: "/account",
262
+ errorCallbackURL: "/sign-up",
263
+ metadata: { agreements: ["account_terms"], locale: "en" },
264
+ },
265
+ });
266
+ expect(await screen.findByTestId("auth-sent-body")).toHaveTextContent(
267
+ messages.auth.signUpLinkSentBody,
268
+ );
269
+ expect(mockSignUpEmail).not.toHaveBeenCalled();
270
+ });
271
+
272
+ it("an AGREEMENTS_REQUIRED answer is shown on the agreements", async () => {
273
+ mockFetch.mockResolvedValue({
274
+ data: null,
275
+ error: { code: "AGREEMENTS_REQUIRED", status: 400 },
276
+ });
277
+ render(<SignUpCard {...linkProps()} />);
278
+ await tick("account_terms");
279
+ await requestLink();
280
+ const agreements = screen.getByTestId("sign-up-agreements");
281
+ await waitFor(() =>
282
+ expect(within(agreements).getByRole("alert")).toHaveTextContent(
283
+ messages.auth.signUpAgreementsRequired,
284
+ ),
285
+ );
286
+ });
287
+
288
+ it.each([
289
+ [429, messages.auth.signUpLinkRateLimited],
290
+ [400, messages.auth.signUpLinkFailed],
291
+ ])("a %s answer shows its copy and no sent card", async (status, copy) => {
292
+ mockFetch.mockResolvedValue({
293
+ data: null,
294
+ error: { code: status === 400 ? "SIGN_UP_CLOSED" : undefined, status },
295
+ });
296
+ render(<SignUpCard {...linkProps()} />);
297
+ await tick("account_terms");
298
+ await requestLink();
299
+ expect(await screen.findByText(copy)).toBeInTheDocument();
300
+ expect(screen.queryByTestId("auth-sent-body")).toBeNull();
301
+ });
302
+
303
+ it("renders a link notice above the card", () => {
304
+ render(<SignUpCard {...linkProps({ linkNotice: messages.auth.signUpLinkExpired })} />);
305
+ expect(screen.getByTestId("sign-up-link-notice")).toHaveTextContent(
306
+ messages.auth.signUpLinkExpired,
307
+ );
308
+ });
309
+ });
@@ -4,13 +4,23 @@
4
4
  * RSC. Branches on `isSignUpOpen()`:
5
5
  * - open → the sign-up form island, wired to Better Auth exactly as
6
6
  * `/sign-in`'s island is (providers + method from auth-env).
7
- * - closed → AuthCard's `invitation` intent: copy only, with the footer's
8
- * "Back to sign in" link. This is the DEFAULT — `apps/web/auth.ts`
9
- * does not enable `emailAndPassword`, so a scaffold offers no
10
- * public sign-up and the page must not advertise one.
7
+ * With the email sign-in link on, the method is the one-field
8
+ * "Send me a magic link" body (#9667 D6): the link creates the
9
+ * account on verify, and a failed link lands back here with
10
+ * `?error=<code>`, mapped to fixed copy.
11
+ * - closed → AuthCard's `invitation` intent: copy only. This is the
12
+ * DEFAULT — `apps/web/auth.ts` does not enable
13
+ * `emailAndPassword`, so a scaffold offers no public sign-up and
14
+ * the page must not advertise one.
11
15
  *
12
- * `AuthShell` (the (auth) group's layout) owns the brand mark and the 400px
13
- * column, so this page passes no `logo` and no framing of its own.
16
+ * The one card (#9662) frames both: the site's mark, "Sign up", the
17
+ * "Already have an account? Sign in" line to `/sign-in` (in place of the
18
+ * footer's cross-link) and the Terms + Privacy acknowledgement. A closed site
19
+ * keeps its invitation copy inside that frame — never a form the server
20
+ * would refuse (operator, 2026-10-24).
21
+ *
22
+ * `AuthShell` (the (auth) group's layout) owns the 400px column; the card
23
+ * carries the site's mark (#8790 D3).
14
24
  */
15
25
 
16
26
  import { localeHref } from "@working-theory/ui";
@@ -26,12 +36,36 @@ import type { ReactElement } from "react";
26
36
 
27
37
  import { getActiveProviderIds, isSignUpOpen } from "../../../../lib/auth-env";
28
38
  import { consentPoliciesFor } from "../../../../lib/consent-policies";
39
+ import { siteLogo } from "../../../../lib/site-logo";
40
+ import { legalFootnote } from "../legal-footnote";
41
+ import { resolveSignInCardText } from "../sign-in/sign-in-text";
29
42
 
30
43
  import { SignUpCard, type SignUpProvider } from "./sign-up-card";
31
- import { siteLogo } from "../../../../lib/site-logo";
32
44
 
33
45
  interface Props {
34
46
  params: Promise<{ locale: string }>;
47
+ searchParams?: Promise<{ error?: string | string[] }>;
48
+ }
49
+
50
+ /**
51
+ * The magic-link verify's `?error=<code>` (#9667 D6), mapped to fixed copy —
52
+ * never the value itself. A repeated `error` reads its first value.
53
+ */
54
+ const EXPIRED_LINK_ERRORS: ReadonlySet<string> = new Set([
55
+ "INVALID_TOKEN",
56
+ "new_user_signup_disabled",
57
+ ]);
58
+
59
+ function linkNoticeKey(
60
+ error: string | string[] | undefined,
61
+ ): "signUpLinkExpired" | "signUpAgreementsRequired" | "signUpFailed" | null {
62
+ if (error === undefined) return null;
63
+ const code = Array.isArray(error) ? error[0] : error;
64
+ if (code === undefined) return null;
65
+ if (EXPIRED_LINK_ERRORS.has(code)) return "signUpLinkExpired";
66
+ return code === "AGREEMENTS_REQUIRED"
67
+ ? "signUpAgreementsRequired"
68
+ : "signUpFailed";
35
69
  }
36
70
 
37
71
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
@@ -42,24 +76,36 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
42
76
 
43
77
  export default async function SignUpPage({
44
78
  params,
79
+ searchParams,
45
80
  }: Props): Promise<ReactElement> {
46
81
  const { locale } = await params;
47
82
  const t = await getTranslations({ locale, namespace: "auth" });
48
83
  const signInHref = localeHref(locale, "/sign-in");
84
+ // The toggle line to /sign-in, and the acknowledgement /sign-in shares.
85
+ const prospect = {
86
+ prompt: t("signUpFooterPrompt"),
87
+ linkLabel: t("signUpFooterLink"),
88
+ href: signInHref,
89
+ };
90
+ const footnote = legalFootnote(
91
+ locale,
92
+ (await resolveSignInCardText(locale)).acknowledgement,
93
+ );
49
94
 
50
95
  if (!isSignUpOpen()) {
51
96
  const closedLabels: AuthCardLabels = {
52
- invitationHeading: t("invitationHeading"),
53
- invitationSubhead: t("invitationSubhead"),
97
+ invitationHeading: t("signUpHeading"),
54
98
  invitationBody: t("invitationBody"),
55
- backToSignIn: t("backToSignIn"),
56
99
  };
57
100
  return (
58
101
  <div className="flex flex-col gap-4" data-testid="sign-up-page">
59
102
  <AuthCard
60
103
  logo={siteLogo()}
61
104
  intent="invitation"
62
- signInHref={signInHref}
105
+ footer="none"
106
+ subhead=""
107
+ prospect={prospect}
108
+ footnote={footnote}
63
109
  labels={closedLabels}
64
110
  />
65
111
  </div>
@@ -71,20 +117,25 @@ export default async function SignUpPage({
71
117
  const providers = active.filter(
72
118
  (id): id is SignUpProvider => id === "google" || id === "github",
73
119
  );
74
- const method = active.includes("email") ? "password" : "sso";
120
+ const method = active.includes("magic-link")
121
+ ? "magic-link"
122
+ : active.includes("email")
123
+ ? "password"
124
+ : "sso";
125
+ const noticeKey =
126
+ method === "magic-link" ? linkNoticeKey((await searchParams)?.error) : null;
75
127
 
76
128
  const labels: AuthCardLabels = {
77
129
  signUpHeading: t("signUpHeading"),
78
- signUpSubhead: t("signUpSubhead"),
79
130
  signUpSubmit: t("signUpSubmit"),
80
- signUpFooterPrompt: t("signUpFooterPrompt"),
81
- signUpFooterLink: t("signUpFooterLink"),
82
131
  ssoPrefix: t("ssoPrefix"),
83
132
  google: t("google"),
84
133
  github: t("github"),
85
134
  email: t("email"),
86
135
  password: t("password"),
87
136
  orContinue: t("orContinue"),
137
+ magicLinkSubmit: t("magicLinkSubmit"),
138
+ emailLinkDivider: t("orContinue"),
88
139
  };
89
140
 
90
141
  // #8438 D13 — the site's sign-up agreements, resolved to this locale. No
@@ -106,7 +157,8 @@ export default async function SignUpPage({
106
157
  providers={providers}
107
158
  method={method}
108
159
  next={localeHref(locale, "/account")}
109
- signInHref={signInHref}
160
+ prospect={prospect}
161
+ footnote={footnote}
110
162
  labels={labels}
111
163
  notConfiguredSubhead={t("signUpNotConfiguredSubhead")}
112
164
  errorLabels={{
@@ -118,6 +170,16 @@ export default async function SignUpPage({
118
170
  agreementPolicies={consentPoliciesFor(locale)}
119
171
  agreementsLegend={t("signUpAgreementsLegend")}
120
172
  locale={locale}
173
+ errorCallbackURL={localeHref(locale, "/sign-up")}
174
+ {...(noticeKey ? { linkNotice: t(noticeKey) } : {})}
175
+ linkLabels={{
176
+ sentHeading: t("sentHeading"),
177
+ sentSubhead: t("sentSubhead"),
178
+ sentBody: t("signUpLinkSentBody"),
179
+ back: t("signUpLinkBack"),
180
+ failed: t("signUpLinkFailed"),
181
+ rateLimited: t("signUpLinkRateLimited"),
182
+ }}
121
183
  />
122
184
  );
123
185
  }
@@ -1,4 +1,4 @@
1
- "use client"; // Better Auth's client calls (signUp.email / signIn.social) + error state
1
+ "use client"; // Better Auth's client calls (signUp.email / signIn.social / $fetch) + error state
2
2
 
3
3
  /**
4
4
  * SignUpCard — the /sign-up page's client island (#5963).
@@ -13,7 +13,10 @@
13
13
  * documented posture the sign-in island already carries for its password
14
14
  * path. The page only renders this island at all when `isSignUpOpen()`.
15
15
  *
16
- * The brand mark is the shell's: no `logo` is passed, or it would render twice.
16
+ * The one card (#9662): the page hands in the site's mark (`logo`), the
17
+ * "Already have an account? Sign in" line (`prospect`, in place of AuthCard's
18
+ * footer cross-link) and the Terms + Privacy acknowledgement (`footnote`).
19
+ * The heading stands alone — no subhead unless the card is inert.
17
20
  *
18
21
  * Error surface: `AuthCard`'s `errors` prop is per-field, so a failed provider
19
22
  * launch lands on `errors.email` (the social row sits inside the password form
@@ -37,6 +40,14 @@
37
40
  * `additionalData`, which Better Auth keeps in the OAuth state for the
38
41
  * callback. The server writes the grants with the user
39
42
  * (`lib/sign-up-agreements.ts`); no cookie carries them.
43
+ *
44
+ * #9667 D6 — the magic-link method. With the email sign-in link on, the card
45
+ * is AuthCard's one-field "Send me a magic link" body, gated the same way.
46
+ * The request goes through the shared client's `$fetch`, never the
47
+ * `magicLinkClient()` plugin (for the reason `sign-in-card.tsx` records), with
48
+ * the agreements and locale in its `metadata` — which is what makes it a
49
+ * sign-up request. The server binds them to the token and creates the account
50
+ * on verify. The sent state never says whether the address had an account.
40
51
  */
41
52
 
42
53
  import {
@@ -47,7 +58,13 @@ import {
47
58
  } from "@working-theory/ui/blocks";
48
59
  import { Alert, AlertDescription } from "@working-theory/ui/primitives";
49
60
  import { SIGN_UP_AGREEMENTS_FIELD } from "@working-theory/validation";
50
- import { useState, type MouseEvent, type ReactElement, type ReactNode } from "react";
61
+ import {
62
+ useRef,
63
+ useState,
64
+ type MouseEvent,
65
+ type ReactElement,
66
+ type ReactNode,
67
+ } from "react";
51
68
 
52
69
  import { authClient, useAuthCardFlow } from "../use-auth-card-flow";
53
70
 
@@ -62,17 +79,35 @@ export interface SignUpAgreement {
62
79
  statement: string;
63
80
  }
64
81
 
82
+ /** Translated copy for the magic-link sign-up (#9667 D6). */
83
+ export interface SignUpLinkLabels {
84
+ sentHeading: string;
85
+ sentSubhead: string;
86
+ sentBody: string;
87
+ /** The sent state's "Back to sign up" link. */
88
+ back: string;
89
+ /** Any failed request other than a rate limit or the agreements. */
90
+ failed: string;
91
+ /** A 429 from Better Auth's per-IP limit. */
92
+ rateLimited: string;
93
+ }
94
+
65
95
  export interface SignUpCardProps {
66
96
  /** The site's mark, centred in the card at 48px (#8790 D3). */
67
97
  logo?: ReactNode;
68
98
  /** Active OAuth providers, exactly as auth-env reports them. */
69
99
  providers: SignUpProvider[];
70
- /** "password" when the email provider is active; otherwise "sso". */
71
- method: "sso" | "password";
100
+ /**
101
+ * "magic-link" when the email sign-in link is active, else "password" when
102
+ * the email provider is, otherwise "sso".
103
+ */
104
+ method: "sso" | "password" | "magic-link";
72
105
  /** Post-sign-up path. */
73
106
  next: string;
74
- /** Href for the footer's "Sign in" cross-link. */
75
- signInHref: string;
107
+ /** The "Already have an account? Sign in" line to /sign-in (#9662). */
108
+ prospect: { prompt: string; linkLabel: string; href: string };
109
+ /** The acknowledgement sentence(s) at the card's foot (#9662). */
110
+ footnote: ReactNode;
76
111
  /** Translated AuthCard labels (resolved server-side by the page). */
77
112
  labels: AuthCardLabels;
78
113
  /** Subhead shown when no provider is configured. */
@@ -92,6 +127,12 @@ export interface SignUpCardProps {
92
127
  agreementsLegend: string;
93
128
  /** The page's locale, sent with the agreements (the statements' language). */
94
129
  locale: string;
130
+ /** Where a failed link lands (`/sign-up`); Better Auth appends `?error`. */
131
+ errorCallbackURL?: string;
132
+ /** Fixed copy for a failed link (`?error=` mapped by the page), shown above the card. */
133
+ linkNotice?: string;
134
+ /** The magic-link method's copy; required when `method` is "magic-link". */
135
+ linkLabels?: SignUpLinkLabels;
95
136
  }
96
137
 
97
138
  /** The server's body key for the ticked ids — also the group's control name. */
@@ -112,7 +153,8 @@ export function SignUpCard({
112
153
  providers,
113
154
  method,
114
155
  next,
115
- signInHref,
156
+ prospect,
157
+ footnote,
116
158
  labels,
117
159
  notConfiguredSubhead,
118
160
  errorLabels,
@@ -121,6 +163,9 @@ export function SignUpCard({
121
163
  agreementsLegend,
122
164
  logo,
123
165
  locale,
166
+ errorCallbackURL,
167
+ linkNotice,
168
+ linkLabels,
124
169
  }: SignUpCardProps): ReactElement {
125
170
  const { errors, providerError, launchProvider, submitPassword } =
126
171
  useAuthCardFlow(next, {
@@ -135,6 +180,11 @@ export function SignUpCard({
135
180
  const [agreementsError, setAgreementsError] = useState<string | undefined>(
136
181
  undefined,
137
182
  );
183
+ const [linkSent, setLinkSent] = useState(false);
184
+ const [linkError, setLinkError] = useState<string | undefined>(undefined);
185
+ // One request at a time: a double-click would spend two of the per-IP
186
+ // sends and mint two live tokens (as sign-in-card.tsx).
187
+ const linkPending = useRef(false);
138
188
  const allAgreed = requiredAgreementIds.every((id) => ticked.has(id));
139
189
  // What the server checks and records (D10): the ticked ids, in the order shown.
140
190
  const agreed = (): { agreements: string[]; locale: string } => ({
@@ -201,8 +251,72 @@ export function SignUpCard({
201
251
  );
202
252
  };
203
253
 
254
+ const requestSignUpLink = async (email: string): Promise<void> => {
255
+ if (!allAgreed || linkPending.current) return;
256
+ linkPending.current = true;
257
+ setAgreementsError(undefined);
258
+ setLinkError(undefined);
259
+ try {
260
+ const { error } = await authClient.$fetch("/sign-in/magic-link", {
261
+ method: "POST",
262
+ body: {
263
+ email,
264
+ // As the password submit: Better Auth wants a name on a new user.
265
+ name: email,
266
+ callbackURL: next,
267
+ errorCallbackURL,
268
+ metadata: agreed(),
269
+ },
270
+ });
271
+ if (!error) {
272
+ setLinkSent(true);
273
+ return;
274
+ }
275
+ if (errorCode({ error }) === AGREEMENTS_REQUIRED) {
276
+ setAgreementsError(errorLabels.agreementsRequired);
277
+ } else {
278
+ setLinkError(
279
+ error.status === 429 ? linkLabels?.rateLimited : linkLabels?.failed,
280
+ );
281
+ }
282
+ } catch {
283
+ setLinkError(linkLabels?.failed);
284
+ } finally {
285
+ linkPending.current = false;
286
+ }
287
+ };
288
+
289
+ if (method === "magic-link" && linkSent && linkLabels) {
290
+ // No `sentTo`: both a new and a taken address get a link, and the state
291
+ // must not say which.
292
+ return (
293
+ <div className="flex flex-col gap-4" data-testid="sign-up-page">
294
+ <AuthCard
295
+ {...(logo !== undefined ? { logo } : {})}
296
+ intent="sent"
297
+ footer="cross-link"
298
+ // "Back to sign up": this page, where a failed link also lands.
299
+ {...(errorCallbackURL !== undefined
300
+ ? { signInHref: errorCallbackURL }
301
+ : {})}
302
+ labels={{
303
+ sentHeading: linkLabels.sentHeading,
304
+ sentSubhead: linkLabels.sentSubhead,
305
+ sentBody: linkLabels.sentBody,
306
+ backToSignIn: linkLabels.back,
307
+ }}
308
+ />
309
+ </div>
310
+ );
311
+ }
312
+
204
313
  return (
205
314
  <div className="flex flex-col gap-4" data-testid="sign-up-page">
315
+ {linkNotice ? (
316
+ <Alert variant="error" data-testid="sign-up-link-notice">
317
+ <AlertDescription>{linkNotice}</AlertDescription>
318
+ </Alert>
319
+ ) : null}
206
320
  {method === "sso" && providerError ? (
207
321
  <Alert variant="error" data-testid="sign-up-provider-error">
208
322
  <AlertDescription>{providerError}</AlertDescription>
@@ -217,11 +331,17 @@ export function SignUpCard({
217
331
  {...(logo !== undefined ? { logo } : {})}
218
332
  intent="sign-up"
219
333
  method={method}
220
- signInHref={signInHref}
221
- {...(notConfigured ? { subhead: notConfiguredSubhead } : {})}
334
+ footer="none"
335
+ subhead={notConfigured ? notConfiguredSubhead : ""}
336
+ prospect={prospect}
337
+ footnote={footnote}
222
338
  providers={providerButtons}
223
- onSubmit={handleSubmit}
224
- errors={errors}
339
+ onSubmit={
340
+ method === "magic-link"
341
+ ? (data) => void requestSignUpLink(data.email)
342
+ : handleSubmit
343
+ }
344
+ errors={linkError ? { ...errors, email: linkError } : errors}
225
345
  labels={labels}
226
346
  />
227
347
  </div>
@@ -19,6 +19,9 @@
19
19
  * 9. On an account path (#7430) the gate speaks to members — the
20
20
  * `account.gate` copy, never `waitlistDashboard.gate` — and its
21
21
  * not-configured headline is a real heading element.
22
+ * 10. The member gate renders the one card (#9662): `/sign-in`'s card with
23
+ * the site's mark, the toggle line to /sign-up and the Terms + Privacy
24
+ * acknowledgement.
22
25
  */
23
26
  import { readFileSync } from "node:fs";
24
27
  import { join } from "node:path";
@@ -407,13 +410,62 @@ describe("AuthedLayout — the signed-out gate on an account path (#7430)", () =
407
410
  await renderSignedOutLayout();
408
411
  expect(gateNamespaces()).toContain("account.gate");
409
412
  expect(gateNamespaces()).not.toContain("waitlistDashboard.gate");
410
- expect(screen.getByTestId("auth-panel-provider-google")).toBeInTheDocument();
413
+ expect(screen.getByTestId("auth-sso-google")).toBeInTheDocument();
411
414
  expect(screen.getByTestId("sign-in-gate-link")).toHaveAttribute(
412
415
  "href",
413
416
  "/sign-in?next=%2Faccount",
414
417
  );
415
418
  });
416
419
 
420
+ // A client component's props cross the RSC boundary: React refuses a
421
+ // function there, so the card's copy must arrive as plain data (#9662 —
422
+ // the 500 a function-valued `backTo` once caused here).
423
+ it.each([
424
+ ["Google", false],
425
+ ["Google + the email link", true],
426
+ ] as const)("hands SignInGate no function in its card props (%s)", async (_name, withEmailLink) => {
427
+ configureGoogle();
428
+ if (withEmailLink) configureEmailLink();
429
+ mockGetSession.mockResolvedValue(null);
430
+ const element = (await AuthedLayout({
431
+ params: Promise.resolve({ locale: "en" }),
432
+ children: <div />,
433
+ })) as React.ReactElement<{ card?: { text: Record<string, unknown> } }>;
434
+ const text = element.props.card?.text;
435
+ expect(text).toBeDefined();
436
+ const functions = Object.entries(text ?? {}).filter(
437
+ ([, value]) => typeof value === "function",
438
+ );
439
+ expect(functions).toEqual([]);
440
+ });
441
+
442
+ it.each([
443
+ ["Google", false],
444
+ ["Google + the email link", true],
445
+ ] as const)("renders the one card with %s: the mark, the toggle line and the acknowledgement (#9662)", async (_name, withEmailLink) => {
446
+ configureGoogle();
447
+ if (withEmailLink) configureEmailLink();
448
+ await renderSignedOutLayout();
449
+ expect(screen.getByTestId("sign-in-page")).toBeInTheDocument();
450
+ expect(screen.queryByTestId("auth-panel-card")).not.toBeInTheDocument();
451
+ expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
452
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
453
+ "href",
454
+ "/sign-up",
455
+ );
456
+ const footnote = screen.getByTestId("auth-card-footnote");
457
+ expect(footnote.querySelector('a[href="/terms"]')).not.toBeNull();
458
+ expect(footnote.querySelector('a[href="/privacy"]')).not.toBeNull();
459
+ if (withEmailLink) {
460
+ expect(screen.getByTestId("auth-submit")).toHaveTextContent(
461
+ "copy:emailLinkSubmit",
462
+ );
463
+ expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
464
+ } else {
465
+ expect(screen.queryByTestId("auth-email")).not.toBeInTheDocument();
466
+ }
467
+ });
468
+
417
469
  it("renders the not-configured headline as a real heading, not a <p>", async () => {
418
470
  await renderSignedOutLayout();
419
471
  expect(gateNamespaces()).toContain("account.gate");
@@ -10,6 +10,9 @@
10
10
  * button; (d) a 500 or a throw shows the error row, never "no
11
11
  * subscription"; (e) a contributed billing section renders after every
12
12
  * one of these; (f) the read asks for `include: "billing"`.
13
+ * #9401 AC-9 (spec docs/specs/9401-show-a-buyer-s-ailk-spec.md D7): a
14
+ * section the site adds to `siteBillingSections` renders after the
15
+ * modules' `accountBillingSections`, each under its `ctx.t(title)`.
13
16
  * #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
14
17
  * D10, D11): `openBillingPortal()` mints the portal through
15
18
  * `billingPortalUrl()` — the hub's route, once, with the cookie init — and
@@ -81,6 +84,7 @@ jest.mock("next/navigation", () => ({
81
84
  import type { AccountBillingSection } from "@working-theory/templates/account";
82
85
 
83
86
  import { accountBillingSections } from "../../../../../lib/account-config";
87
+ import { siteBillingSections } from "../../../../../lib/site-contributions";
84
88
  import { openBillingPortal } from "../actions";
85
89
 
86
90
  // The Billing route's own page, loaded with `import()`: architecture.yaml bans
@@ -276,6 +280,40 @@ describe("AC-2 (e) — contributed billing sections render after the base conten
276
280
  });
277
281
  });
278
282
 
283
+ describe("#9401 AC-9 — a site's own section renders after the modules' (D7)", () => {
284
+ const moduleSection: AccountBillingSection = {
285
+ id: "module",
286
+ title: "settings.billing.currentPlan",
287
+ render: () => <p data-testid="module-body">Module</p>,
288
+ };
289
+ const siteSection: AccountBillingSection = {
290
+ id: "site",
291
+ title: "settings.billing.title",
292
+ render: () => <p data-testid="site-body">Site</p>,
293
+ };
294
+
295
+ beforeAll(() => {
296
+ accountBillingSections.push(moduleSection);
297
+ siteBillingSections.push(siteSection);
298
+ });
299
+
300
+ afterAll(() => {
301
+ accountBillingSections.splice(accountBillingSections.indexOf(moduleSection), 1);
302
+ siteBillingSections.splice(siteBillingSections.indexOf(siteSection), 1);
303
+ });
304
+
305
+ it("renders the module's section, then the site's, each under its translated title", async () => {
306
+ await renderPage();
307
+ const contributed = screen.getByTestId("account-settings-module");
308
+ const site = screen.getByTestId("account-settings-site");
309
+ expect(within(contributed).getByTestId("module-body")).toBeInTheDocument();
310
+ expect(within(site).getByTestId("site-body")).toBeInTheDocument();
311
+ expect(within(contributed).getByRole("heading", { level: 2 })).toHaveTextContent("Current plan");
312
+ expect(within(site).getByRole("heading", { level: 2 })).toHaveTextContent("Billing");
313
+ expect(contributed.compareDocumentPosition(site) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
314
+ });
315
+ });
316
+
279
317
  describe("#8217 AC-1 — the single site's Manage billing (D10, D11)", () => {
280
318
  const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
281
319