create-ailk 0.11.2 → 0.11.4

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 (76) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/surfaces.d.ts +4 -5
  4. package/dist/surfaces.js +50 -8
  5. package/package.json +3 -3
  6. package/templates/.env.example +29 -0
  7. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  8. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  9. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  10. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  11. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  12. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  13. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  14. package/templates/apps/api/src/server.ts +28 -0
  15. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  16. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  17. package/templates/apps/api/src/vercel-handler.ts +58 -12
  18. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  19. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  20. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  21. package/templates/apps/web/.env.example +4 -0
  22. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  23. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  24. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  25. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  26. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  27. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  28. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  29. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  30. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  31. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  32. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  33. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
  34. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
  35. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  36. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  37. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  38. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
  39. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  40. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  41. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  42. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  43. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
  44. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
  45. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  46. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  47. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  48. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  49. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  50. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  51. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  52. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  53. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  54. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  55. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  56. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  57. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  58. package/templates/apps/web/app/[locale]/layout.tsx +41 -28
  59. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  60. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  61. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  62. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  63. package/templates/apps/web/auth.ts +29 -5
  64. package/templates/apps/web/jest.config.cjs +5 -0
  65. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  66. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  67. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  68. package/templates/apps/web/lib/account-config.ts +219 -11
  69. package/templates/apps/web/lib/auth-email.ts +104 -2
  70. package/templates/apps/web/lib/auth-env.ts +37 -5
  71. package/templates/apps/web/lib/auth-routes.ts +33 -4
  72. package/templates/apps/web/messages/en.json +148 -21
  73. package/templates/database/CHANGELOG.md +14 -0
  74. package/templates/database/package.json +1 -1
  75. package/versions.json +33 -33
  76. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The `SignInCard`'s translated copy, resolved on the server (#7435 § D9).
3
+ *
4
+ * One helper so `/sign-in` and the `(authed)` gate render the SAME labels from
5
+ * the same `signIn` keys — the gate composes `SignInCard` when the email
6
+ * sign-in link is on, and a second copy of this object would drift.
7
+ */
8
+
9
+ import type { AuthCardLabels } from "@working-theory/ui/blocks";
10
+ import { getTranslations } from "next-intl/server";
11
+
12
+ import type { SignInEmailLinkLabels } from "./sign-in-card";
13
+
14
+ export interface SignInCardText {
15
+ labels: AuthCardLabels;
16
+ notConfiguredSubhead: string;
17
+ errorLabels: { signInFailed: string; providerFailed: string };
18
+ emailLinkLabels: SignInEmailLinkLabels;
19
+ /** The notice for a link that failed verification (`?error=INVALID_TOKEN`). */
20
+ emailLinkExpired: string;
21
+ }
22
+
23
+ export async function resolveSignInCardText(
24
+ locale: string,
25
+ ): Promise<SignInCardText> {
26
+ const t = await getTranslations({ locale, namespace: "signIn" });
27
+ return {
28
+ labels: {
29
+ signInHeading: t("heading"),
30
+ signInSubhead: t("subhead"),
31
+ ssoPrefix: t("ssoPrefix"),
32
+ google: t("google"),
33
+ github: t("github"),
34
+ email: t("email"),
35
+ password: t("password"),
36
+ forgot: t("forgot"),
37
+ signInSubmit: t("submit"),
38
+ orContinue: t("orContinue"),
39
+ emailLinkSubmit: t("emailLinkSubmit"),
40
+ emailLinkDivider: t("emailLinkDivider"),
41
+ },
42
+ notConfiguredSubhead: t("notConfiguredSubhead"),
43
+ errorLabels: {
44
+ signInFailed: t("signInFailed"),
45
+ providerFailed: t("providerFailed"),
46
+ },
47
+ emailLinkLabels: {
48
+ sentHeading: t("emailLinkSentHeading"),
49
+ sentSubhead: t("emailLinkSentSubhead"),
50
+ sentBody: t("emailLinkSentBody"),
51
+ backToSignIn: t("emailLinkBack"),
52
+ failed: t("emailLinkFailed"),
53
+ rateLimited: t("emailLinkRateLimited"),
54
+ },
55
+ emailLinkExpired: t("emailLinkExpired"),
56
+ };
57
+ }
@@ -9,17 +9,47 @@
9
9
  * 5. AILK_AUTH_REQUIRED has no effect on the guard (D9/AC7).
10
10
  * 6. SignInGate links to `/sign-in?next=<pathname>` in both the providers
11
11
  * and the zero-provider branches (#4984).
12
+ * 7. The providers are resolved on the SERVER, from the real provider env,
13
+ * by the layout and handed to the client gate as a prop — the gate reads
14
+ * no env of its own (#7419: in the browser that env is always empty, so
15
+ * the gate always claimed "Sign-in is not configured").
16
+ * 8. With the email sign-in link on (#7435 § D9), the gate composes the
17
+ * sign-in card — Google + "Email me a sign-in link" — in place of
18
+ * AuthPanel and its inert password form; off, it renders as before.
19
+ * 9. On an account path (#7430) the gate speaks to members — the
20
+ * `account.gate` copy, never `waitlistDashboard.gate` — and its
21
+ * not-configured headline is a real heading element.
12
22
  */
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
13
26
  import { render, screen } from "@testing-library/react";
27
+ import { getTranslations } from "next-intl/server";
28
+
29
+ import en from "../../../../messages/en.json";
14
30
 
15
31
  jest.mock("next-intl/server", () => ({
16
32
  __esModule: true,
17
33
  getTranslations: jest.fn().mockResolvedValue((key: string) => `copy:${key}`),
18
34
  }));
19
35
 
36
+ // The enabled locales `isAccountPath()` strips a prefix against — the same
37
+ // mock the locale layout's suite uses (production reads the baked env var).
38
+ jest.mock("@working-theory/web-i18n", () => ({
39
+ __esModule: true,
40
+ readLocales: jest.fn().mockReturnValue(["en"]),
41
+ }));
42
+
43
+ // The request's `x-pathname` (forwarded by middleware.ts) — unset by default,
44
+ // the way a direct render with no middleware sees it.
45
+ let mockRequestPathname: string | undefined;
20
46
  jest.mock("next/headers", () => ({
21
47
  __esModule: true,
22
- headers: jest.fn().mockResolvedValue(new Headers()),
48
+ headers: jest.fn(async () => {
49
+ const h = new Headers();
50
+ if (mockRequestPathname) h.set("x-pathname", mockRequestPathname);
51
+ return h;
52
+ }),
23
53
  }));
24
54
 
25
55
  const mockGetSession = jest.fn();
@@ -31,13 +61,6 @@ jest.mock("../../../../auth", () => ({
31
61
  },
32
62
  }));
33
63
 
34
- const mockGetActiveProviderIds = jest.fn();
35
- // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth-env.ts is a pure env-introspection module with no external boundary to mock instead.
36
- jest.mock("../../../../lib/auth-env", () => ({
37
- __esModule: true,
38
- getActiveProviderIds: () => mockGetActiveProviderIds(),
39
- }));
40
-
41
64
  const mockUsePathname = jest.fn();
42
65
  jest.mock("next/navigation", () => ({
43
66
  __esModule: true,
@@ -53,11 +76,43 @@ jest.mock("better-auth/react", () => ({
53
76
  import AuthedLayout from "../layout";
54
77
  import { SignInGate } from "../sign-in-gate";
55
78
 
79
+ // The provider env `lib/auth-env` reads. The layout tests drive it for real —
80
+ // no mock of auth-env — so they exercise the server-side resolution end to end.
81
+ const PROVIDER_ENV_KEYS = [
82
+ "GOOGLE_CLIENT_ID",
83
+ "GOOGLE_CLIENT_SECRET",
84
+ "GITHUB_CLIENT_ID",
85
+ "GITHUB_CLIENT_SECRET",
86
+ "AUTH_EMAIL_SERVER",
87
+ "AUTH_EMAIL_FROM",
88
+ // #7435 § D1 — the email sign-in link's four conjuncts.
89
+ "AUTH_EMAIL_WEBHOOK_SECRET",
90
+ "BETTER_AUTH_URL",
91
+ "BETTER_AUTH_SECRET",
92
+ ] as const;
93
+
94
+ const ORIGINAL_ENV = process.env;
95
+
96
+ beforeEach(() => {
97
+ process.env = { ...ORIGINAL_ENV };
98
+ for (const key of PROVIDER_ENV_KEYS) delete process.env[key];
99
+ mockRequestPathname = undefined;
100
+ });
101
+
102
+ afterAll(() => {
103
+ process.env = ORIGINAL_ENV;
104
+ });
105
+
106
+ function configureGoogle(): void {
107
+ process.env.GOOGLE_CLIENT_ID = "google-client-id";
108
+ process.env.GOOGLE_CLIENT_SECRET = "google-client-secret";
109
+ }
110
+
56
111
  describe("AuthedLayout — the (authed) session guard", () => {
57
112
  beforeEach(() => {
58
113
  mockGetSession.mockReset();
59
- mockGetActiveProviderIds.mockReset().mockReturnValue(["google"]);
60
114
  mockUsePathname.mockReset().mockReturnValue("/en/waitlist");
115
+ configureGoogle();
61
116
  });
62
117
 
63
118
  it("renders SignInGate when there is no session", async () => {
@@ -103,7 +158,6 @@ describe("AuthedLayout — the (authed) session guard", () => {
103
158
  });
104
159
 
105
160
  it("ignores AILK_AUTH_REQUIRED entirely — never read by the guard", async () => {
106
- const original = process.env.AILK_AUTH_REQUIRED;
107
161
  process.env.AILK_AUTH_REQUIRED = "0";
108
162
  mockGetSession.mockResolvedValue(null);
109
163
  const element = await AuthedLayout({
@@ -112,7 +166,48 @@ describe("AuthedLayout — the (authed) session guard", () => {
112
166
  });
113
167
  render(element);
114
168
  expect(screen.getByTestId("sign-in-gate")).toBeInTheDocument();
115
- process.env.AILK_AUTH_REQUIRED = original;
169
+ });
170
+
171
+ // ── #7419 — providers resolved server-side, from the real env ─────────────
172
+
173
+ it("renders the Google sign-in control when Google is configured server-side (#7419 AC-1)", async () => {
174
+ mockGetSession.mockResolvedValue(null);
175
+ const element = await AuthedLayout({
176
+ params: Promise.resolve({ locale: "en" }),
177
+ children: <div data-testid="dashboard-content" />,
178
+ });
179
+ render(element);
180
+ expect(screen.getByTestId("auth-panel-provider-google")).toBeInTheDocument();
181
+ expect(screen.queryByTestId("auth-panel-provider-github")).not.toBeInTheDocument();
182
+ expect(screen.queryByTestId("sign-in-gate-empty")).not.toBeInTheDocument();
183
+ });
184
+
185
+ it("renders the not-configured EmptyState when no provider is configured (#7419 AC-2)", async () => {
186
+ for (const key of PROVIDER_ENV_KEYS) delete process.env[key];
187
+ mockGetSession.mockResolvedValue(null);
188
+ const element = await AuthedLayout({
189
+ params: Promise.resolve({ locale: "en" }),
190
+ children: <div data-testid="dashboard-content" />,
191
+ });
192
+ render(element);
193
+ expect(screen.getByTestId("sign-in-gate-empty")).toBeInTheDocument();
194
+ expect(screen.getByText("copy:emptyHeadline")).toBeInTheDocument();
195
+ expect(screen.queryByTestId("auth-panel-card")).not.toBeInTheDocument();
196
+ });
197
+
198
+ it("filters an unsupported provider id out before handing providers to the gate", async () => {
199
+ for (const key of PROVIDER_ENV_KEYS) delete process.env[key];
200
+ // "email" is reported by getActiveProviderIds but is not a supported
201
+ // AuthPanel provider (google | github) — treated as zero active providers.
202
+ process.env.AUTH_EMAIL_SERVER = "smtp://mail.example.test";
203
+ process.env.AUTH_EMAIL_FROM = "noreply@example.test";
204
+ mockGetSession.mockResolvedValue(null);
205
+ const element = await AuthedLayout({
206
+ params: Promise.resolve({ locale: "en" }),
207
+ children: <div data-testid="dashboard-content" />,
208
+ });
209
+ render(element);
210
+ expect(screen.getByTestId("sign-in-gate-empty")).toBeInTheDocument();
116
211
  });
117
212
  });
118
213
 
@@ -124,37 +219,59 @@ const GATE_LABELS = {
124
219
 
125
220
  describe("<SignInGate/> — the dashboard's own front door", () => {
126
221
  beforeEach(() => {
127
- mockGetActiveProviderIds.mockReset().mockReturnValue(["google", "github"]);
128
222
  mockUsePathname.mockReset().mockReturnValue("/en/waitlist");
129
223
  mockSignInSocial.mockReset();
130
224
  });
131
225
 
132
- it("renders AuthPanel with the active providers", () => {
133
- render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" />);
226
+ it("renders AuthPanel with the providers it is given", () => {
227
+ render(
228
+ <SignInGate
229
+ labels={GATE_LABELS}
230
+ signInPath="/sign-in"
231
+ providers={["google", "github"]}
232
+ />,
233
+ );
134
234
  expect(screen.getByTestId("sign-in-gate")).toBeInTheDocument();
135
235
  expect(screen.getByTestId("auth-panel-card")).toBeInTheDocument();
236
+ expect(screen.getByTestId("auth-panel-provider-google")).toBeInTheDocument();
237
+ expect(screen.getByTestId("auth-panel-provider-github")).toBeInTheDocument();
238
+ });
239
+
240
+ // #7419 — the browser has no provider env. Every provider env var is unset
241
+ // here (the top-level beforeEach), exactly as in a browser bundle; the gate
242
+ // must still render the providers its server-rendered parent resolved.
243
+ it("renders the Google button from the prop with no provider env present (#7419)", () => {
244
+ render(
245
+ <SignInGate labels={GATE_LABELS} signInPath="/sign-in" providers={["google"]} />,
246
+ );
247
+ expect(screen.getByTestId("auth-panel-provider-google")).toBeInTheDocument();
248
+ expect(screen.queryByTestId("sign-in-gate-empty")).not.toBeInTheDocument();
249
+ });
250
+
251
+ it("starts a Google social sign-in back to the current page on click", () => {
252
+ render(
253
+ <SignInGate labels={GATE_LABELS} signInPath="/sign-in" providers={["google"]} />,
254
+ );
255
+ screen.getByTestId("auth-panel-provider-google").click();
256
+ expect(mockSignInSocial).toHaveBeenCalledWith({
257
+ provider: "google",
258
+ callbackURL: "/en/waitlist",
259
+ });
136
260
  });
137
261
 
138
262
  it("renders an EmptyState when no provider is active", () => {
139
- mockGetActiveProviderIds.mockReturnValue([]);
140
- render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" />);
263
+ render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" providers={[]} />);
141
264
  expect(screen.getByTestId("sign-in-gate-empty")).toBeInTheDocument();
142
265
  expect(screen.getByText("No providers")).toBeInTheDocument();
143
266
  expect(screen.queryByTestId("auth-panel-card")).not.toBeInTheDocument();
144
267
  });
145
268
 
146
- it("filters an inactive/unsupported provider id out of the panel", () => {
147
- mockGetActiveProviderIds.mockReturnValue(["email"]);
148
- render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" />);
149
- // "email" is reported by getActiveProviderIds but is not a supported
150
- // AuthPanel provider (google | github) — treated as zero active providers.
151
- expect(screen.getByTestId("sign-in-gate-empty")).toBeInTheDocument();
152
- });
153
-
154
269
  // ── #4984 — the link to apps/web's own /sign-in page ──────────────────────
155
270
 
156
271
  it("links to /sign-in?next=<pathname> under the AuthPanel", () => {
157
- render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" />);
272
+ render(
273
+ <SignInGate labels={GATE_LABELS} signInPath="/sign-in" providers={["google"]} />,
274
+ );
158
275
  const link = screen.getByTestId("sign-in-gate-link");
159
276
  expect(link).toHaveAttribute("href", "/sign-in?next=%2Fen%2Fwaitlist");
160
277
  expect(link).toHaveTextContent("Go to the sign-in page");
@@ -162,8 +279,7 @@ describe("<SignInGate/> — the dashboard's own front door", () => {
162
279
  });
163
280
 
164
281
  it("links to /sign-in?next=<pathname> in the zero-provider branch too", () => {
165
- mockGetActiveProviderIds.mockReturnValue([]);
166
- render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" />);
282
+ render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" providers={[]} />);
167
283
  expect(screen.getByTestId("sign-in-gate-empty")).toBeInTheDocument();
168
284
  expect(screen.getByTestId("sign-in-gate-link")).toHaveAttribute(
169
285
  "href",
@@ -173,10 +289,172 @@ describe("<SignInGate/> — the dashboard's own front door", () => {
173
289
 
174
290
  it("encodes the current pathname — /account round-trips as ?next=%2Faccount", () => {
175
291
  mockUsePathname.mockReturnValue("/account");
176
- render(<SignInGate labels={GATE_LABELS} signInPath="/sign-in" />);
292
+ render(
293
+ <SignInGate labels={GATE_LABELS} signInPath="/sign-in" providers={["google"]} />,
294
+ );
295
+ expect(screen.getByTestId("sign-in-gate-link")).toHaveAttribute(
296
+ "href",
297
+ "/sign-in?next=%2Faccount",
298
+ );
299
+ });
300
+ });
301
+
302
+ // #7419 AC-3 — a "use client" module is bundled for the browser, where
303
+ // server-only env reads silently come back empty. The gate must not reach for
304
+ // provider env at all; its parent RSC resolves the providers and passes them.
305
+ describe("sign-in-gate.tsx — no server env read in a client component (#7419)", () => {
306
+ const source = readFileSync(join(__dirname, "..", "sign-in-gate.tsx"), "utf8");
307
+
308
+ it("is a client component", () => {
309
+ expect(source.startsWith('"use client"')).toBe(true);
310
+ });
311
+
312
+ it("does not import lib/auth-env", () => {
313
+ expect(source).not.toMatch(/from\s+["'][^"']*lib\/auth-env["']/);
314
+ expect(source).not.toMatch(/require\(\s*["'][^"']*lib\/auth-env["']\s*\)/);
315
+ });
316
+
317
+ it("does not read process.env", () => {
318
+ expect(source).not.toMatch(/process\.env/);
319
+ });
320
+ });
321
+
322
+ // ── #7435 — the email sign-in link on the gate (AC-5, AC-4) ─────────────────
323
+
324
+ function configureEmailLink(): void {
325
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "webhook-secret";
326
+ process.env.AUTH_EMAIL_FROM = "noreply@example.test";
327
+ process.env.BETTER_AUTH_URL = "https://site.example";
328
+ process.env.BETTER_AUTH_SECRET = "a-real-secret";
329
+ }
330
+
331
+ async function renderSignedOutLayout(): Promise<void> {
332
+ mockGetSession.mockResolvedValue(null);
333
+ const element = await AuthedLayout({
334
+ params: Promise.resolve({ locale: "en" }),
335
+ children: <div data-testid="dashboard-content" />,
336
+ });
337
+ render(element);
338
+ }
339
+
340
+ describe("AuthedLayout — the email sign-in link (#7435)", () => {
341
+ beforeEach(() => {
342
+ mockGetSession.mockReset();
343
+ mockUsePathname.mockReset().mockReturnValue("/en/waitlist");
344
+ });
345
+
346
+ it("offers Google and 'Email me a sign-in link', with no AuthPanel password form", async () => {
347
+ configureGoogle();
348
+ configureEmailLink();
349
+ await renderSignedOutLayout();
350
+ expect(screen.getByTestId("sign-in-gate")).toBeInTheDocument();
351
+ expect(screen.getByTestId("auth-sso-google")).toBeInTheDocument();
352
+ expect(screen.getByTestId("auth-email-link-submit")).toHaveTextContent(
353
+ "copy:emailLinkSubmit",
354
+ );
355
+ expect(screen.queryByTestId("auth-panel-password")).not.toBeInTheDocument();
356
+ expect(screen.queryByTestId("auth-panel-card")).not.toBeInTheDocument();
357
+ expect(screen.getByTestId("sign-in-gate-link")).toHaveAttribute(
358
+ "href",
359
+ "/sign-in?next=%2Fen%2Fwaitlist",
360
+ );
361
+ });
362
+
363
+ it("renders the email-link form, not the EmptyState, when it is the only method", async () => {
364
+ configureEmailLink();
365
+ await renderSignedOutLayout();
366
+ expect(screen.getByTestId("auth-email-link-form")).toBeInTheDocument();
367
+ expect(screen.queryByTestId("sign-in-gate-empty")).not.toBeInTheDocument();
368
+ expect(screen.getByTestId("sign-in-gate-link")).toBeInTheDocument();
369
+ });
370
+
371
+ it.each([
372
+ "AUTH_EMAIL_WEBHOOK_SECRET",
373
+ "AUTH_EMAIL_FROM",
374
+ "BETTER_AUTH_URL",
375
+ "BETTER_AUTH_SECRET",
376
+ ])("renders no email-link form when %s is unset (AC-4)", async (name) => {
377
+ configureGoogle();
378
+ configureEmailLink();
379
+ delete process.env[name];
380
+ await renderSignedOutLayout();
381
+ expect(screen.queryByTestId("auth-email-link-form")).not.toBeInTheDocument();
382
+ expect(screen.getByTestId("auth-panel-provider-google")).toBeInTheDocument();
383
+ });
384
+ });
385
+
386
+ // ── #7430 — the account area's gate speaks to members ──────────────────────
387
+
388
+ function gateNamespaces(): unknown[] {
389
+ return jest
390
+ .mocked(getTranslations)
391
+ .mock.calls.map((call) => (call[0] as { namespace?: string }).namespace);
392
+ }
393
+
394
+ describe("AuthedLayout — the signed-out gate on an account path (#7430)", () => {
395
+ beforeEach(() => {
396
+ mockGetSession.mockReset();
397
+ jest.mocked(getTranslations).mockClear();
398
+ mockUsePathname.mockReset().mockReturnValue("/account");
399
+ mockRequestPathname = "/account";
400
+ });
401
+
402
+ it("reads the member-facing account.gate copy, never waitlistDashboard.gate", async () => {
403
+ configureGoogle();
404
+ await renderSignedOutLayout();
405
+ expect(gateNamespaces()).toContain("account.gate");
406
+ expect(gateNamespaces()).not.toContain("waitlistDashboard.gate");
407
+ expect(screen.getByTestId("auth-panel-provider-google")).toBeInTheDocument();
177
408
  expect(screen.getByTestId("sign-in-gate-link")).toHaveAttribute(
178
409
  "href",
179
410
  "/sign-in?next=%2Faccount",
180
411
  );
181
412
  });
413
+
414
+ it("renders the not-configured headline as a real heading, not a <p>", async () => {
415
+ await renderSignedOutLayout();
416
+ expect(gateNamespaces()).toContain("account.gate");
417
+ expect(screen.getByTestId("account-sign-in-gate")).toBeInTheDocument();
418
+ expect(screen.queryByTestId("sign-in-gate-empty")).not.toBeInTheDocument();
419
+ expect(
420
+ screen.getByRole("heading", { level: 1, name: "copy:emptyHeadline" }),
421
+ ).toBeInTheDocument();
422
+ expect(screen.getByText("copy:emptyDescription")).toBeInTheDocument();
423
+ // With no sign-in method on, /sign-in is a dead end that shows operator
424
+ // copy — the gate's one action goes back to the site instead.
425
+ expect(screen.queryByTestId("sign-in-gate-link")).not.toBeInTheDocument();
426
+ const home = screen.getByTestId("account-sign-in-gate-home");
427
+ expect(home).toHaveAttribute("href", "/");
428
+ expect(home).toHaveTextContent("copy:homeLink");
429
+ expect(screen.queryByTestId("dashboard-content")).not.toBeInTheDocument();
430
+ });
431
+
432
+ it("reads account.gate under a locale prefix and nested account paths too", async () => {
433
+ mockRequestPathname = "/en/account/settings";
434
+ await renderSignedOutLayout();
435
+ expect(gateNamespaces()).toContain("account.gate");
436
+ expect(screen.getByRole("heading", { level: 1 })).toBeInTheDocument();
437
+ expect(screen.getByTestId("account-sign-in-gate-home")).toHaveAttribute(
438
+ "href",
439
+ "/",
440
+ );
441
+ });
442
+
443
+ it("keeps waitlistDashboard.gate on an operator path", async () => {
444
+ mockRequestPathname = "/waitlist";
445
+ await renderSignedOutLayout();
446
+ expect(gateNamespaces()).toContain("waitlistDashboard.gate");
447
+ expect(gateNamespaces()).not.toContain("account.gate");
448
+ expect(screen.getByTestId("sign-in-gate-empty")).toBeInTheDocument();
449
+ expect(screen.queryByTestId("account-sign-in-gate")).not.toBeInTheDocument();
450
+ });
451
+
452
+ it("ships member copy — no operator language in en.json's account.gate", () => {
453
+ const copy = Object.values(en.account.gate).join(" ");
454
+ expect(copy).not.toMatch(/operator/i);
455
+ expect(en.account.gate.homeLink).toBeTruthy();
456
+ expect(en.account.gate.emptyHeadline).not.toBe(
457
+ en.waitlistDashboard.gate.emptyHeadline,
458
+ );
459
+ });
182
460
  });
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Tests for /[locale]/account/inbox/[kind]/[id] — one Inbox item (#7430,
3
+ * spec 7423 U4; D8's detail contract).
4
+ *
5
+ * Covers:
6
+ * 1. A known kind + id renders the shared `InboxItemFrame` through the
7
+ * kind's own `renderDetail`: a back link to the Inbox, the kind as the
8
+ * caption, the item's title as the `<h1>`, and the kind's fields.
9
+ * 2. An unknown kind, or an id the kind did not load, is a not-found —
10
+ * never an empty frame.
11
+ * 3. A failed read renders an error with Retry (D12's posture).
12
+ */
13
+ import { fireEvent, render, screen, within } from "@testing-library/react";
14
+
15
+ jest.mock("next-intl/server", () => {
16
+ const msgs: unknown = require("../../../../../messages/en.json");
17
+ const dig = (obj: unknown, path: string): unknown =>
18
+ path.split(".").reduce<unknown>(
19
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
20
+ obj,
21
+ );
22
+ return {
23
+ __esModule: true,
24
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
25
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
26
+ const messages = dig(msgs, ns);
27
+ return (key: string, values?: Record<string, unknown>): string => {
28
+ const value = dig(messages, key);
29
+ if (typeof value !== "string") return `${ns}.${key}`;
30
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
31
+ values && name in values ? String(values[name]) : m,
32
+ );
33
+ };
34
+ },
35
+ };
36
+ });
37
+
38
+ jest.mock("next/headers", () => ({
39
+ __esModule: true,
40
+ headers: jest.fn().mockResolvedValue(new Headers()),
41
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
42
+ }));
43
+
44
+ const mockGetSession = jest.fn();
45
+ // eslint-disable-next-line no-restricted-syntax -- #507: mock-internal rewrite pending; auth.ts's lazy Better-Auth singleton has no HTTP/DB boundary a unit test of the session fields can mock without standing up Prisma.
46
+ jest.mock("../../../../../auth", () => ({
47
+ __esModule: true,
48
+ auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
49
+ }));
50
+
51
+ const mockSignupMe = jest.fn();
52
+ const mockCheckoutsMe = jest.fn();
53
+ jest.mock("@working-theory/api-client", () => ({
54
+ __esModule: true,
55
+ waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
56
+ flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
57
+ subscription: { me: jest.fn() },
58
+ }));
59
+
60
+ const mockNotFound = jest.fn(() => {
61
+ throw new Error("NEXT_NOT_FOUND");
62
+ });
63
+ const mockRefresh = jest.fn();
64
+ jest.mock("next/navigation", () => ({
65
+ __esModule: true,
66
+ notFound: () => mockNotFound(),
67
+ useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
68
+ usePathname: () => "/account/inbox/purchase/fc_done",
69
+ }));
70
+
71
+ import Page from "../inbox/[kind]/[id]/page";
72
+
73
+ const SESSION = {
74
+ user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
75
+ };
76
+
77
+ const CHECKOUT = {
78
+ id: "fc_done",
79
+ flowSlug: "founder-flow",
80
+ experimentKey: "ailk-founder-diagnostic",
81
+ offerKey: "pro",
82
+ status: "completed",
83
+ amountMinor: 4900,
84
+ currency: "usd",
85
+ period: "monthly",
86
+ completedAt: "2026-09-04T10:00:00.000Z",
87
+ createdAt: "2026-09-04T09:58:00.000Z",
88
+ };
89
+
90
+ beforeEach(() => {
91
+ mockGetSession.mockReset().mockResolvedValue(SESSION);
92
+ mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
93
+ mockSignupMe.mockReset();
94
+ mockNotFound.mockClear();
95
+ mockRefresh.mockReset();
96
+ });
97
+
98
+ async function renderItem(kind: string, id: string, locale = "en"): Promise<void> {
99
+ const element = await Page({ params: Promise.resolve({ locale, kind, id }) });
100
+ render(element);
101
+ }
102
+
103
+ describe("one Inbox item", () => {
104
+ it("renders the kind's detail inside the shared item frame", async () => {
105
+ await renderItem("purchase", "fc_done");
106
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("pro");
107
+ expect(screen.getByText("Inbox").closest("a")).toHaveAttribute("href", "/account");
108
+ expect(screen.getAllByText("My purchases").length).toBeGreaterThan(0);
109
+ const fields = screen.getByTestId("account-item-fields");
110
+ expect(within(fields).getByText("Amount")).toBeInTheDocument();
111
+ expect(within(fields).getByText("Period")).toBeInTheDocument();
112
+ expect(within(fields).getByText("monthly")).toBeInTheDocument();
113
+ });
114
+
115
+ it("decodes an encoded id segment", async () => {
116
+ mockCheckoutsMe.mockResolvedValue({ ok: true, body: [{ ...CHECKOUT, id: "fc done" }] });
117
+ await renderItem("purchase", "fc%20done");
118
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("pro");
119
+ });
120
+
121
+ it("a non-default locale's back link returns to that locale's Inbox", async () => {
122
+ await renderItem("purchase", "fc_done", "fr");
123
+ expect(screen.getByText("Inbox").closest("a")).toHaveAttribute("href", "/fr/account");
124
+ });
125
+
126
+ it("an unknown kind is a not-found", async () => {
127
+ await expect(renderItem("nope", "fc_done")).rejects.toThrow("NEXT_NOT_FOUND");
128
+ expect(mockCheckoutsMe).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it("an id the kind did not load is a not-found", async () => {
132
+ await expect(renderItem("purchase", "fc_other")).rejects.toThrow("NEXT_NOT_FOUND");
133
+ });
134
+
135
+ it("a failed read renders an error with Retry, not a not-found", async () => {
136
+ mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
137
+ await renderItem("purchase", "fc_done");
138
+ expect(mockNotFound).not.toHaveBeenCalled();
139
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load this item.");
140
+ fireEvent.click(screen.getByTestId("inbox-retry"));
141
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
142
+ });
143
+ });