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,7 +17,7 @@
17
17
  * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
18
18
  * the real en.json, so the `auth` copy is asserted by its real strings.
19
19
  */
20
- import { render, screen } from "@testing-library/react";
20
+ import { render, screen, within } from "@testing-library/react";
21
21
 
22
22
  const mockGetActiveProviderIds = jest.fn();
23
23
  const mockIsSignUpOpen = jest.fn();
@@ -217,12 +217,51 @@ describe("/sign-up follows isSignUpOpen()", () => {
217
217
  it("renders the invitation intent when sign-up is closed", async () => {
218
218
  mockIsSignUpOpen.mockReturnValue(false);
219
219
  await renderPage(SignUpPage);
220
- expect(screen.getByTestId("auth-invitation-body")).toBeInTheDocument();
220
+ expect(screen.getByTestId("auth-invitation-body")).toHaveTextContent(
221
+ en.auth.invitationBody,
222
+ );
221
223
  expect(
222
- screen.getByRole("heading", { name: en.auth.invitationHeading }),
224
+ screen.getByRole("heading", { name: en.auth.signUpHeading }),
223
225
  ).toBeInTheDocument();
224
226
  expect(screen.queryByTestId("auth-submit")).not.toBeInTheDocument();
225
227
  });
228
+
229
+ // The one card (#9662) frames both branches: the mark, "Sign up", the toggle
230
+ // line to /sign-in (no footer cross-link) and the acknowledgement.
231
+ it.each([
232
+ ["closed", false],
233
+ ["open", true],
234
+ ] as const)("frames the %s card: mark, 'Sign up', toggle line, footnote (#9662)", async (_name, open) => {
235
+ mockIsSignUpOpen.mockReturnValue(open);
236
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
237
+ await renderPage(SignUpPage);
238
+ expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
239
+ expect(screen.getByRole("heading", { name: "Sign up" })).toBeInTheDocument();
240
+ expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
241
+ /^Already have an account\? Sign in$/,
242
+ );
243
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
244
+ "href",
245
+ "/sign-in",
246
+ );
247
+ expect(screen.queryByTestId("auth-footer-link")).not.toBeInTheDocument();
248
+ expect(screen.getByTestId("auth-card-footnote")).toHaveTextContent(
249
+ /^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
250
+ );
251
+ const footnote = within(screen.getByTestId("auth-card-footnote"));
252
+ expect(footnote.getByRole("link", { name: "Terms of Service" })).toHaveAttribute("href", "/terms");
253
+ expect(footnote.getByRole("link", { name: "Privacy Policy" })).toHaveAttribute("href", "/privacy");
254
+ });
255
+
256
+ it("offers 'or' + Google under the open card's form, with the consent checkboxes kept (#9662; #8438 D13)", async () => {
257
+ mockIsSignUpOpen.mockReturnValue(true);
258
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
259
+ await renderPage(SignUpPage);
260
+ expect(screen.getByLabelText("Email")).toBeInTheDocument();
261
+ expect(screen.getByTestId("auth-divider")).toHaveTextContent(/^or$/);
262
+ expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
263
+ expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
264
+ });
226
265
  });
227
266
 
228
267
  describe("/forgot-password and /reset-password follow the reset predicate", () => {
@@ -266,9 +305,9 @@ describe("/forgot-password and /reset-password follow the reset predicate", () =
266
305
 
267
306
  // ── Every non-form page routes back to sign-in ───────────────────────────────
268
307
 
308
+ // A closed /sign-up routes back through its toggle line instead (#9662, above).
269
309
  describe("the copy-only intents route back to sign-in", () => {
270
310
  it.each([
271
- ["sign-up (closed)", SignUpPage],
272
311
  ["forgot-password (unavailable)", ForgotPasswordPage],
273
312
  ["reset-password (unavailable)", ResetPasswordPage],
274
313
  ["verify-email", VerifyEmailPage],
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The acknowledgement at the foot of every auth card (#8790 D6) — "You
3
+ * acknowledge that you read, and agree to our Terms of Service and our Privacy
4
+ * Policy." (#9662) — linking the site's own policy routes through the inline
5
+ * link variant, so the card never hardcodes a path.
6
+ *
7
+ * One builder for `/sign-in`, `/sign-up` and the `(authed)` gate.
8
+ * `caption` is the muted footnote role; a default `Text` would re-impose body
9
+ * size and the foreground colour over the card's footnote wrapper.
10
+ */
11
+
12
+ import { localeHref } from "@working-theory/ui";
13
+ import { Link, Text } from "@working-theory/ui/primitives";
14
+ import type { ReactElement } from "react";
15
+
16
+ import type { SignInCardText } from "./sign-in/sign-in-text";
17
+
18
+ export function legalFootnote(
19
+ locale: string,
20
+ text: SignInCardText["acknowledgement"],
21
+ ): ReactElement {
22
+ return (
23
+ <Text as="p" variant="caption">
24
+ {text.lead}{" "}
25
+ <Link variant="inline" href={localeHref(locale, "/terms")}>
26
+ {text.terms}
27
+ </Link>{" "}
28
+ {text.and}{" "}
29
+ <Link variant="inline" href={localeHref(locale, "/privacy")}>
30
+ {text.privacy}
31
+ </Link>
32
+ {text.end}
33
+ </Text>
34
+ );
35
+ }
@@ -24,11 +24,16 @@
24
24
  * (i) the hinted email (#8757 D9): a valid single `?email=` is the starting
25
25
  * value of every email input of the card; an invalid or repeated value,
26
26
  * or none, leaves them empty as before. It signs no one in.
27
+ * (j) the one card (#9662): the site's logo and "Sign in to <site>" from
28
+ * the deployment's own data — a crafted query parameter cannot change
29
+ * the name — the "Don't have an account? Sign up" line linking
30
+ * /sign-up, and the Terms + Privacy acknowledgement.
27
31
  *
28
- * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
29
- * the real en.json, so `signIn` copy is asserted by its real strings.
32
+ * `next-intl/server` is mocked here against the real en.json, with `{name}`
33
+ * placeholders filled from the values passed, so `signIn` copy is asserted
34
+ * by its real strings and the heading by the name actually interpolated.
30
35
  */
31
- import { render, screen } from "@testing-library/react";
36
+ import { render, screen, within } from "@testing-library/react";
32
37
  import userEvent from "@testing-library/user-event";
33
38
 
34
39
  const mockGetActiveProviderIds = jest.fn();
@@ -75,7 +80,31 @@ jest.mock("@working-theory/api-client", () => ({
75
80
  __esModule: true,
76
81
  hubSignIn: { clientInfo: (...args: unknown[]) => mockClientInfo(...args) },
77
82
  }));
83
+ // The global shim (__tests__/__mocks__/next-intl-server.ts) returns messages
84
+ // uninterpolated; this suite fills `{name}` placeholders so the heading's
85
+ // organization name is the one the page actually passed (#9662).
86
+ jest.mock("next-intl/server", () => {
87
+ const messages = jest.requireActual("../../../../../messages/en.json") as Record<
88
+ string,
89
+ Record<string, string>
90
+ >;
91
+ return {
92
+ __esModule: true,
93
+ getTranslations: async ({ namespace }: { namespace: string }) => {
94
+ const catalog = messages[namespace];
95
+ const t = (key: string, values?: Record<string, string>): string =>
96
+ (catalog?.[key] ?? `${namespace}.${key}`).replace(
97
+ /\{(\w+)\}/g,
98
+ (match, name: string) => values?.[name] ?? match,
99
+ );
100
+ t.has = (key: string): boolean => catalog?.[key] !== undefined;
101
+ return t;
102
+ },
103
+ };
104
+ });
105
+
78
106
  import { mintContinuation } from "../../../../api/auth/hub/continuation";
107
+ import { SITE_NAME } from "../../../../../lib/site-brand";
79
108
  import en from "../../../../../messages/en.json";
80
109
  import Page, { generateMetadata } from "../page";
81
110
 
@@ -119,7 +148,9 @@ describe("/sign-in page", () => {
119
148
  expect(screen.queryByTestId("auth-submit")).toBeNull();
120
149
  expect(screen.queryByTestId("auth-footer-link")).toBeNull();
121
150
  expect(screen.queryByText(/create an account/i)).toBeNull();
122
- expect(screen.getByText(en.signIn.heading)).toBeInTheDocument();
151
+ expect(
152
+ screen.getByRole("heading", { name: `Sign in to ${SITE_NAME}` }),
153
+ ).toBeInTheDocument();
123
154
  });
124
155
 
125
156
  it("(b) email + OAuth: password body with both social buttons, no sign-up footer", async () => {
@@ -241,7 +272,7 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
241
272
  expect(screen.getByTestId("auth-submit")).toHaveTextContent(
242
273
  en.signIn.emailLinkSubmit,
243
274
  );
244
- expect(en.signIn.emailLinkSubmit).toBe("Email me a sign-in link");
275
+ expect(en.signIn.emailLinkSubmit).toBe("Send me a magic link");
245
276
  // One "or" divider between the primary and Google (#8790 D5).
246
277
  expect(screen.getAllByTestId("auth-divider")).toHaveLength(1);
247
278
  expect(screen.queryByTestId("auth-email-link-divider")).toBeNull();
@@ -252,9 +283,11 @@ describe("/sign-in page — the email sign-in link (#7435)", () => {
252
283
  await renderPage();
253
284
  expect(screen.getByTestId("auth-email")).toBeInTheDocument();
254
285
  expect(screen.queryByText(en.signIn.notConfiguredSubhead)).toBeNull();
255
- // "Welcome back" stands alone — no subhead (#8790 D3).
256
- expect(screen.getByText(en.signIn.heading)).toBeInTheDocument();
257
- expect(en.signIn.heading).toBe("Welcome back");
286
+ // The heading stands alone — no subhead (#8790 D3); #9662 names the site.
287
+ expect(
288
+ screen.getByRole("heading", { name: `Sign in to ${SITE_NAME}` }),
289
+ ).toBeInTheDocument();
290
+ expect(en.signIn.heading).toBe("Sign in to {site}");
258
291
  expect(screen.queryByTestId("auth-method-switch")).toBeNull();
259
292
  });
260
293
 
@@ -415,3 +448,79 @@ describe("/sign-in page — the hinted email (#8757 D9)", () => {
415
448
  },
416
449
  );
417
450
  });
451
+
452
+ describe("/sign-in page — the one card (#9662)", () => {
453
+ beforeEach(() => {
454
+ mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
455
+ });
456
+
457
+ it("(j) renders the site's logo and 'Sign in to <site>' from the deployment's data", async () => {
458
+ await renderPage("/account");
459
+ expect(screen.getByTestId("auth-card-logo")).toBeInTheDocument();
460
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
461
+ `Sign in to ${SITE_NAME}`,
462
+ );
463
+ expect(screen.getByLabelText(en.signIn.email)).toBe(
464
+ screen.getByTestId("auth-email"),
465
+ );
466
+ expect(en.signIn.email).toBe("Email");
467
+ });
468
+
469
+ it("(j) a crafted query parameter cannot change the organization name", async () => {
470
+ const crafted = "Evil Corp";
471
+ expect(SITE_NAME).not.toBe(crafted);
472
+ const el = await Page({
473
+ params: Promise.resolve({ locale: "en" }),
474
+ searchParams: Promise.resolve({
475
+ next: `/account?site=${encodeURIComponent(crafted)}`,
476
+ site: crafted,
477
+ organization: crafted,
478
+ name: crafted,
479
+ siteName: crafted,
480
+ }),
481
+ });
482
+ render(el);
483
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent(
484
+ `Sign in to ${SITE_NAME}`,
485
+ );
486
+ expect(document.body.textContent).not.toContain(crafted);
487
+ });
488
+
489
+ it("(j) the prospect line toggles to /sign-up, with no trailing period", async () => {
490
+ await renderPage("/account");
491
+ expect(screen.getByTestId("auth-card-prospect")).toHaveTextContent(
492
+ /^Don't have an account\? Sign up$/,
493
+ );
494
+ expect(screen.getByTestId("auth-card-prospect-link")).toHaveAttribute(
495
+ "href",
496
+ "/sign-up",
497
+ );
498
+ });
499
+
500
+ it("(j) 'Send me a magic link' first, the in-place password switch, then 'or' and Google", async () => {
501
+ await renderPage("/account");
502
+ expect(screen.getByTestId("auth-submit")).toHaveTextContent(
503
+ "Send me a magic link",
504
+ );
505
+ expect(screen.getByTestId("auth-method-switch")).toHaveTextContent(
506
+ "Sign in using password",
507
+ );
508
+ expect(screen.getByTestId("auth-divider")).toHaveTextContent(/^or$/);
509
+ expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
510
+ });
511
+
512
+ it("(j) ends on the Terms of Service + Privacy Policy acknowledgement, linking the site's own routes", async () => {
513
+ await renderPage("/account");
514
+ const footnote = screen.getByTestId("auth-card-footnote");
515
+ expect(footnote).toHaveTextContent(
516
+ /^You acknowledge that you read, and agree to our Terms of Service and our Privacy Policy\.$/,
517
+ );
518
+ expect(
519
+ within(footnote).getByRole("link", { name: "Terms of Service" }),
520
+ ).toHaveAttribute("href", "/terms");
521
+ expect(
522
+ within(footnote).getByRole("link", { name: "Privacy Policy" }),
523
+ ).toHaveAttribute("href", "/privacy");
524
+ expect(within(footnote).getAllByRole("link")).toHaveLength(2);
525
+ });
526
+ });
@@ -18,14 +18,19 @@
18
18
  *
19
19
  * Since #5963 the page lives in the `(auth)` route group — the URL is
20
20
  * unchanged (a group contributes no path segment) but the frame is
21
- * `AuthShell`, not the marketing Nav + Footer. The shell owns the brand mark
22
- * and the 400px column, so this page passes NO `logo` (it would render twice)
23
- * and no framing of its own.
21
+ * `AuthShell`, not the marketing Nav + Footer. The shell owns the 400px
22
+ * column; the card carries the site's mark (#8790 D3).
23
+ *
24
+ * The one card (#9662): "Sign in to <site>" (the name is the deployment's,
25
+ * read in `resolveSignInCardText`, never the request's), the "Don't have an
26
+ * account? Sign up" line to `/sign-up`, and the Terms + Privacy
27
+ * acknowledgement.
24
28
  *
25
29
  * Signed-in visitors are not redirected here — the Nav's session-aware action
26
30
  * cluster (Account / Sign out) is the affordance for them.
27
31
  */
28
32
 
33
+ import { hubSignIn } from "@working-theory/api-client";
29
34
  import { localeHref } from "@working-theory/ui";
30
35
  import { accountEmailHintSchema } from "@working-theory/validation";
31
36
  import type { Metadata } from "next";
@@ -36,14 +41,13 @@ import {
36
41
  getActiveProviderIds,
37
42
  resetSendConfigured,
38
43
  } from "../../../../lib/auth-env";
39
-
40
- import { hubSignIn } from "@working-theory/api-client";
41
- import { Link, Text } from "@working-theory/ui/primitives";
42
44
  import { siteLogo } from "../../../../lib/site-logo";
43
45
  import {
44
46
  CONTINUE_PATH,
45
47
  openContinuation,
46
48
  } from "../../../api/auth/hub/continuation";
49
+ import { legalFootnote } from "../legal-footnote";
50
+
47
51
  import { resolveNextPath } from "./next-path";
48
52
  import { SignInCard, type SignInProvider } from "./sign-in-card";
49
53
  import { resolveSignInCardText, type SignInCardText } from "./sign-in-text";
@@ -151,27 +155,7 @@ export default async function SignInPage({
151
155
  const defaultEmail = hintedEmail(rawEmail);
152
156
 
153
157
  const spoke = await spokeFromNext(next);
154
- // The acknowledgement (#8790 D6) — the hub's own policy pages, through the
155
- // inline link variant; the card never hardcodes a path.
156
- // `caption` is the muted footnote role; a default `Text` would re-impose
157
- // body size and the foreground colour over the card's footnote wrapper.
158
- const footnote = (
159
- <Text as="p" variant="caption">
160
- {text.acknowledgement.lead}{" "}
161
- <Link variant="inline" href={localeHref(locale, "/terms")}>
162
- {text.acknowledgement.terms}
163
- </Link>
164
- ,{" "}
165
- <Link variant="inline" href={localeHref(locale, "/privacy")}>
166
- {text.acknowledgement.privacy}
167
- </Link>
168
- , and{" "}
169
- <Link variant="inline" href={localeHref(locale, "/working-agreement")}>
170
- {text.acknowledgement.workingAgreement}
171
- </Link>
172
- .
173
- </Text>
174
- );
158
+ const t = await getTranslations({ locale, namespace: "signIn" });
175
159
  return (
176
160
  <SignInCard
177
161
  logo={siteLogo()}
@@ -179,19 +163,12 @@ export default async function SignInPage({
179
163
  ? {
180
164
  backLink: {
181
165
  href: `${spoke.origin}/account`,
182
- label: text.backTo(spoke.name),
183
- },
184
- }
185
- : {})}
186
- {...(text.prospect
187
- ? {
188
- prospect: {
189
- ...text.prospect,
190
- href: localeHref(locale, "/sign-up"),
166
+ label: t("backTo", { site: spoke.name }),
191
167
  },
192
168
  }
193
169
  : {})}
194
- footnote={footnote}
170
+ prospect={{ ...text.prospect, href: localeHref(locale, "/sign-up") }}
171
+ footnote={legalFootnote(locale, text.acknowledgement)}
195
172
  providers={providers}
196
173
  method={method}
197
174
  next={next}
@@ -11,12 +11,12 @@
11
11
  * method from auth-env and hands them in; this component never reads env
12
12
  * itself.
13
13
  *
14
- * The brand mark is the SHELL's since #5963 — this island renders no `logo`,
15
- * or it would appear twice on the page.
14
+ * The site's mark arrives as `logo` (#8790 D3) and renders centred in the
15
+ * card; the shell draws no brand row.
16
16
  *
17
- * No sign-up affordance anywhere: `footer="none"` drops the "Not a member?
18
- * Create an account" cross-link (Better Auth `emailAndPassword.disableSignUp`
19
- * posture).
17
+ * `footer="none"` drops AuthCard's "Not a member? Create an account"
18
+ * cross-link; the page's `prospect` line ("Don't have an account? Sign up",
19
+ * #9662) is the one route to `/sign-up`.
20
20
  *
21
21
  * Password path: `apps/web/auth.ts` does not enable `emailAndPassword` today,
22
22
  * so `signIn.email` is wired but inert (Better Auth answers with an error)
@@ -242,7 +242,7 @@ export function SignInCard({
242
242
  {...(logo !== undefined ? { logo } : {})}
243
243
  {...(forgotHref !== undefined ? { forgotHref } : {})}
244
244
  {...(forgot !== undefined ? { forgot } : {})}
245
- // "Welcome back" stands alone (#8790 D3): no subhead unless the card
245
+ // The heading stands alone (#8790 D3): no subhead unless the card
246
246
  // is inert and must say why.
247
247
  subhead={notConfigured ? notConfiguredSubhead : ""}
248
248
  {...(prospect !== undefined ? { prospect } : {})}
@@ -4,26 +4,36 @@
4
4
  * One helper so `/sign-in` and the `(authed)` gate render the SAME labels from
5
5
  * the same `signIn` keys — the gate composes `SignInCard` when the email
6
6
  * sign-in link is on, and a second copy of this object would drift.
7
+ *
8
+ * The heading names the site — "Sign in to <site>" (#9662) — from
9
+ * `SITE_NAME`, the deployment's own display name (env, else `_site.mdx`),
10
+ * never from the request.
11
+ *
12
+ * Plain data only: the gate hands this object to a client component, and a
13
+ * function cannot cross that boundary (`/sign-in` resolves its "Back to
14
+ * <spoke>" line itself).
7
15
  */
8
16
 
9
17
  import type { AuthCardLabels } from "@working-theory/ui/blocks";
10
18
  import { getTranslations } from "next-intl/server";
11
19
 
20
+ import { SITE_NAME } from "../../../../lib/site-brand";
21
+
12
22
  import type { SignInEmailLinkLabels } from "./sign-in-card";
13
23
 
14
24
  export interface SignInCardText {
15
25
  labels: AuthCardLabels;
16
- /** "Back to {site}" — the spoke's name goes in (#8790 D2). */
17
- backTo: (site: string) => string;
18
- /** The acknowledgement sentence's parts (#8790 D6). */
26
+ /** The acknowledgement sentence's parts (#8790 D6; Terms + Privacy, #9662). */
19
27
  acknowledgement: {
20
28
  lead: string;
21
29
  terms: string;
30
+ and: string;
22
31
  privacy: string;
23
- workingAgreement: string;
32
+ /** The sentence's closing mark — a message, so a locale can use its own. */
33
+ end: string;
24
34
  };
25
- /** The prospect line, when the site's messages carry it (#8790 D3). */
26
- prospect?: { prompt: string; linkLabel: string };
35
+ /** The prospect line toggling to /sign-up (#8790 D3; on by default, #9662). */
36
+ prospect: { prompt: string; linkLabel: string };
27
37
  notConfiguredSubhead: string;
28
38
  errorLabels: { signInFailed: string; providerFailed: string };
29
39
  emailLinkLabels: SignInEmailLinkLabels;
@@ -31,32 +41,13 @@ export interface SignInCardText {
31
41
  emailLinkExpired: string;
32
42
  }
33
43
 
34
- type HasKey = Parameters<Awaited<ReturnType<typeof getTranslations>>["has"]>[0];
35
- function optional(
36
- t: Awaited<ReturnType<typeof getTranslations>>,
37
- key: string,
38
- ): boolean {
39
- // A partial `getTranslations` mock (the (authed) gate suite's) carries no
40
- // `has`; an absent `has` reads as an absent key, never a throw.
41
- return typeof t.has === "function" && t.has(key as HasKey);
42
- }
43
-
44
- function prospect(
45
- prompt: string,
46
- linkLabel: string,
47
- ): { prospect: { prompt: string; linkLabel: string } } | null {
48
- return prompt.trim() && linkLabel.trim()
49
- ? { prospect: { prompt, linkLabel } }
50
- : null;
51
- }
52
-
53
44
  export async function resolveSignInCardText(
54
45
  locale: string,
55
46
  ): Promise<SignInCardText> {
56
47
  const t = await getTranslations({ locale, namespace: "signIn" });
57
48
  return {
58
49
  labels: {
59
- signInHeading: t("heading"),
50
+ signInHeading: t("heading", { site: SITE_NAME }),
60
51
  ssoPrefix: t("ssoPrefix"),
61
52
  google: t("google"),
62
53
  github: t("github"),
@@ -70,20 +61,14 @@ export async function resolveSignInCardText(
70
61
  useEmailLink: t("useEmailLink"),
71
62
  emailPlaceholder: t("emailPlaceholder"),
72
63
  },
73
- backTo: (site) => t("backTo", { site }),
74
64
  acknowledgement: {
75
65
  lead: t("acknowledgement"),
76
66
  terms: t("termsLink"),
67
+ and: t("acknowledgementAnd"),
77
68
  privacy: t("privacyLink"),
78
- workingAgreement: t("workingAgreementLink"),
69
+ end: t("acknowledgementEnd"),
79
70
  },
80
- // The prospect line (#8790 D3) is OFF until a site ADDS both keys to its
81
- // messages — the shipped catalog carries neither (every leaf must be a
82
- // non-empty string), so presence is the switch. The keys are optional
83
- // extensions of the typed catalog, hence the cast on `has`.
84
- ...(optional(t, "prospectPrompt") && optional(t, "prospectLink")
85
- ? prospect(t("prospectPrompt" as never), t("prospectLink" as never))
86
- : {}),
71
+ prospect: { prompt: t("prospectPrompt"), linkLabel: t("prospectLink") },
87
72
 
88
73
  notConfiguredSubhead: t("notConfiguredSubhead"),
89
74
  errorLabels: {
@@ -0,0 +1,108 @@
1
+ /**
2
+ * `/sign-up`'s method and its failed-link notice (#9667 D6, AC-3).
3
+ *
4
+ * With the email sign-in link active, an open `/sign-up` renders AuthCard's
5
+ * one-field "Send me a magic link" body; without it, #9662's password or
6
+ * provider body. A failed link lands back here with `?error=<code>`, mapped
7
+ * to fixed copy and never echoed.
8
+ *
9
+ * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
10
+ * the real en.json.
11
+ */
12
+ import { render, screen } from "@testing-library/react";
13
+
14
+ const mockGetActiveProviderIds = jest.fn();
15
+ // eslint-disable-next-line no-restricted-syntax -- #507: auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
16
+ jest.mock("../../../../../lib/auth-env", () => ({
17
+ __esModule: true,
18
+ getActiveProviderIds: () => mockGetActiveProviderIds(),
19
+ isSignUpOpen: () => true,
20
+ isResetAvailable: () => false,
21
+ resetSendConfigured: () => false,
22
+ }));
23
+
24
+ jest.mock("better-auth/react", () => ({
25
+ __esModule: true,
26
+ createAuthClient: () => ({
27
+ signIn: { social: jest.fn(), email: jest.fn() },
28
+ signUp: { email: jest.fn() },
29
+ $fetch: jest.fn(),
30
+ }),
31
+ }));
32
+
33
+ import en from "../../../../../messages/en.json";
34
+ import SignUpPage from "../page";
35
+
36
+ async function renderSignUp(
37
+ searchParams: Record<string, string | string[]> = {},
38
+ ): Promise<void> {
39
+ render(
40
+ await SignUpPage({
41
+ params: Promise.resolve({ locale: "en" }),
42
+ searchParams: Promise.resolve(searchParams),
43
+ }),
44
+ );
45
+ }
46
+
47
+ beforeEach(() => {
48
+ mockGetActiveProviderIds.mockReset();
49
+ });
50
+
51
+ describe("the method (D6)", () => {
52
+ it('with "magic-link" active: one Email field, Send me a magic link, no password, the agreements, "or" + Google', async () => {
53
+ mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
54
+ await renderSignUp();
55
+ expect(screen.getByTestId("auth-submit")).toHaveTextContent(
56
+ en.auth.magicLinkSubmit,
57
+ );
58
+ expect(screen.queryByTestId("auth-password")).toBeNull();
59
+ expect(screen.getByTestId("sign-up-agreements")).toBeInTheDocument();
60
+ expect(screen.getByTestId("auth-divider")).toHaveTextContent(
61
+ en.auth.orContinue,
62
+ );
63
+ expect(screen.getByTestId("auth-social-google")).toBeInTheDocument();
64
+ });
65
+
66
+ it('with only ["google", "email"]: the password body', async () => {
67
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
68
+ await renderSignUp();
69
+ expect(screen.getByTestId("auth-password")).toBeInTheDocument();
70
+ expect(screen.getByTestId("auth-submit")).toHaveTextContent(
71
+ en.auth.signUpSubmit,
72
+ );
73
+ });
74
+ });
75
+
76
+ describe("the failed-link notice (D6)", () => {
77
+ beforeEach(() => {
78
+ mockGetActiveProviderIds.mockReturnValue(["magic-link"]);
79
+ });
80
+
81
+ it.each([
82
+ ["INVALID_TOKEN", en.auth.signUpLinkExpired],
83
+ ["new_user_signup_disabled", en.auth.signUpLinkExpired],
84
+ ["AGREEMENTS_REQUIRED", en.auth.signUpAgreementsRequired],
85
+ ["AGREEMENTS_UNAVAILABLE", en.auth.signUpFailed],
86
+ ])("?error=%s renders its copy", async (error, copy) => {
87
+ await renderSignUp({ error });
88
+ expect(screen.getByTestId("sign-up-link-notice")).toHaveTextContent(copy);
89
+ });
90
+
91
+ it("a repeated error reads its first value, and the raw value is never echoed", async () => {
92
+ await renderSignUp({ error: ["INVALID_TOKEN", "<b>x</b>"] });
93
+ const notice = screen.getByTestId("sign-up-link-notice");
94
+ expect(notice).toHaveTextContent(en.auth.signUpLinkExpired);
95
+ expect(notice).not.toHaveTextContent("INVALID_TOKEN");
96
+ });
97
+
98
+ it("no error, no notice", async () => {
99
+ await renderSignUp();
100
+ expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
101
+ });
102
+
103
+ it("the password method shows no link notice", async () => {
104
+ mockGetActiveProviderIds.mockReturnValue(["email"]);
105
+ await renderSignUp({ error: "INVALID_TOKEN" });
106
+ expect(screen.queryByTestId("sign-up-link-notice")).toBeNull();
107
+ });
108
+ });