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
@@ -4,13 +4,30 @@
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
+ * `?next=` (#9912 D4, D5) is read through `resolveNextPath`, the call
23
+ * `/sign-in` makes. The validated `next` is where every method completes —
24
+ * the password verification link, the magic link and Google — and both the
25
+ * "Sign in" toggle and the failed-link return carry it, so a buyer sent here
26
+ * by a spoke's round trip ends back on the spoke. A refused or absent `next`
27
+ * gives `/account` and the bare links, as before.
28
+ *
29
+ * `AuthShell` (the (auth) group's layout) owns the 400px column; the card
30
+ * carries the site's mark (#8790 D3).
14
31
  */
15
32
 
16
33
  import { localeHref } from "@working-theory/ui";
@@ -26,12 +43,40 @@ import type { ReactElement } from "react";
26
43
 
27
44
  import { getActiveProviderIds, isSignUpOpen } from "../../../../lib/auth-env";
28
45
  import { consentPoliciesFor } from "../../../../lib/consent-policies";
46
+ import { siteLogo } from "../../../../lib/site-logo";
47
+ import { legalFootnote } from "../legal-footnote";
48
+ import { hrefWithNext, resolveNextPath } from "../sign-in/next-path";
49
+ import { resolveSignInCardText } from "../sign-in/sign-in-text";
29
50
 
30
51
  import { SignUpCard, type SignUpProvider } from "./sign-up-card";
31
- import { siteLogo } from "../../../../lib/site-logo";
32
52
 
33
53
  interface Props {
34
54
  params: Promise<{ locale: string }>;
55
+ searchParams?: Promise<{
56
+ next?: string | string[];
57
+ error?: string | string[];
58
+ }>;
59
+ }
60
+
61
+ /**
62
+ * The magic-link verify's `?error=<code>` (#9667 D6), mapped to fixed copy —
63
+ * never the value itself. A repeated `error` reads its first value.
64
+ */
65
+ const EXPIRED_LINK_ERRORS: ReadonlySet<string> = new Set([
66
+ "INVALID_TOKEN",
67
+ "new_user_signup_disabled",
68
+ ]);
69
+
70
+ function linkNoticeKey(
71
+ error: string | string[] | undefined,
72
+ ): "signUpLinkExpired" | "signUpAgreementsRequired" | "signUpFailed" | null {
73
+ if (error === undefined) return null;
74
+ const code = Array.isArray(error) ? error[0] : error;
75
+ if (code === undefined) return null;
76
+ if (EXPIRED_LINK_ERRORS.has(code)) return "signUpLinkExpired";
77
+ return code === "AGREEMENTS_REQUIRED"
78
+ ? "signUpAgreementsRequired"
79
+ : "signUpFailed";
35
80
  }
36
81
 
37
82
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
@@ -42,24 +87,38 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
42
87
 
43
88
  export default async function SignUpPage({
44
89
  params,
90
+ searchParams,
45
91
  }: Props): Promise<ReactElement> {
46
92
  const { locale } = await params;
93
+ const { next: rawNext, error } = (await searchParams) ?? {};
47
94
  const t = await getTranslations({ locale, namespace: "auth" });
48
- const signInHref = localeHref(locale, "/sign-in");
95
+ const fallback = localeHref(locale, "/account");
96
+ const next = resolveNextPath(rawNext, fallback);
97
+ // The toggle line to /sign-in, and the acknowledgement /sign-in shares.
98
+ const prospect = {
99
+ prompt: t("signUpFooterPrompt"),
100
+ linkLabel: t("signUpFooterLink"),
101
+ href: hrefWithNext(localeHref(locale, "/sign-in"), next, fallback),
102
+ };
103
+ const footnote = legalFootnote(
104
+ locale,
105
+ (await resolveSignInCardText(locale)).acknowledgement,
106
+ );
49
107
 
50
108
  if (!isSignUpOpen()) {
51
109
  const closedLabels: AuthCardLabels = {
52
- invitationHeading: t("invitationHeading"),
53
- invitationSubhead: t("invitationSubhead"),
110
+ invitationHeading: t("signUpHeading"),
54
111
  invitationBody: t("invitationBody"),
55
- backToSignIn: t("backToSignIn"),
56
112
  };
57
113
  return (
58
114
  <div className="flex flex-col gap-4" data-testid="sign-up-page">
59
115
  <AuthCard
60
116
  logo={siteLogo()}
61
117
  intent="invitation"
62
- signInHref={signInHref}
118
+ footer="none"
119
+ subhead=""
120
+ prospect={prospect}
121
+ footnote={footnote}
63
122
  labels={closedLabels}
64
123
  />
65
124
  </div>
@@ -71,20 +130,24 @@ export default async function SignUpPage({
71
130
  const providers = active.filter(
72
131
  (id): id is SignUpProvider => id === "google" || id === "github",
73
132
  );
74
- const method = active.includes("email") ? "password" : "sso";
133
+ const method = active.includes("magic-link")
134
+ ? "magic-link"
135
+ : active.includes("email")
136
+ ? "password"
137
+ : "sso";
138
+ const noticeKey = method === "magic-link" ? linkNoticeKey(error) : null;
75
139
 
76
140
  const labels: AuthCardLabels = {
77
141
  signUpHeading: t("signUpHeading"),
78
- signUpSubhead: t("signUpSubhead"),
79
142
  signUpSubmit: t("signUpSubmit"),
80
- signUpFooterPrompt: t("signUpFooterPrompt"),
81
- signUpFooterLink: t("signUpFooterLink"),
82
143
  ssoPrefix: t("ssoPrefix"),
83
144
  google: t("google"),
84
145
  github: t("github"),
85
146
  email: t("email"),
86
147
  password: t("password"),
87
148
  orContinue: t("orContinue"),
149
+ magicLinkSubmit: t("magicLinkSubmit"),
150
+ emailLinkDivider: t("orContinue"),
88
151
  };
89
152
 
90
153
  // #8438 D13 — the site's sign-up agreements, resolved to this locale. No
@@ -105,8 +168,9 @@ export default async function SignUpPage({
105
168
  logo={siteLogo()}
106
169
  providers={providers}
107
170
  method={method}
108
- next={localeHref(locale, "/account")}
109
- signInHref={signInHref}
171
+ next={next}
172
+ prospect={prospect}
173
+ footnote={footnote}
110
174
  labels={labels}
111
175
  notConfiguredSubhead={t("signUpNotConfiguredSubhead")}
112
176
  errorLabels={{
@@ -118,6 +182,20 @@ export default async function SignUpPage({
118
182
  agreementPolicies={consentPoliciesFor(locale)}
119
183
  agreementsLegend={t("signUpAgreementsLegend")}
120
184
  locale={locale}
185
+ errorCallbackURL={hrefWithNext(
186
+ localeHref(locale, "/sign-up"),
187
+ next,
188
+ fallback,
189
+ )}
190
+ {...(noticeKey ? { linkNotice: t(noticeKey) } : {})}
191
+ linkLabels={{
192
+ sentHeading: t("sentHeading"),
193
+ sentSubhead: t("sentSubhead"),
194
+ sentBody: t("signUpLinkSentBody"),
195
+ back: t("signUpLinkBack"),
196
+ failed: t("signUpLinkFailed"),
197
+ rateLimited: t("signUpLinkRateLimited"),
198
+ }}
121
199
  />
122
200
  );
123
201
  }
@@ -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
 
@@ -19,7 +19,8 @@
19
19
  *
20
20
  * After any of the three, one plain line when some of the member's billing
21
21
  * is on a Stripe account this spoke's key cannot read (#8217 D12), then
22
- * each section a module contributes (`accountBillingSections`, #8231 D4).
22
+ * each section a module contributes (`accountBillingSections`, #8231 D4),
23
+ * then each section the site adds (`siteBillingSections`, #9401 D7).
23
24
  */
24
25
 
25
26
  import { AccountSettingsFrame, AccountSettingsPane, CurrentPlanCard } from "@working-theory/templates/account";
@@ -29,6 +30,7 @@ import { getTranslations } from "next-intl/server";
29
30
  import type { ReactElement } from "react";
30
31
 
31
32
  import { accountBillingSections } from "../../../../../lib/account-config";
33
+ import { siteBillingSections } from "../../../../../lib/site-contributions";
32
34
  import { accountSession } from "../account-data";
33
35
  import { openBillingPortal } from "../actions";
34
36
  import { type BillingState, loadBilling } from "../billing-source";
@@ -161,7 +163,7 @@ export default async function AccountBillingPage({ params }: Props): Promise<Rea
161
163
  </div>
162
164
  ) : null}
163
165
 
164
- {accountBillingSections.map((section) => (
166
+ {[...accountBillingSections, ...siteBillingSections].map((section) => (
165
167
  <SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
166
168
  {section.render(ctx)}
167
169
  </SettingsSection>