create-ailk 0.11.1 → 0.11.3

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 (105) hide show
  1. package/component-catalog.json +12 -1
  2. package/component-catalog.md +6 -5
  3. package/dist/content-payloads.js +2 -2
  4. package/dist/module-architecture.js +11 -0
  5. package/dist/surfaces.d.ts +4 -5
  6. package/dist/surfaces.js +50 -8
  7. package/package.json +3 -3
  8. package/templates/.env.example +42 -3
  9. package/templates/apps/api/.env.example +5 -0
  10. package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
  11. package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
  12. package/templates/apps/api/src/__tests__/server.test.ts +5 -1
  13. package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
  14. package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
  15. package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
  16. package/templates/apps/api/src/config/index.ts +136 -0
  17. package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
  18. package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
  19. package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
  20. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
  21. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
  22. package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
  23. package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
  24. package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
  25. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
  26. package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
  27. package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
  28. package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
  29. package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
  30. package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
  31. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
  32. package/templates/apps/api/src/middleware/auth.ts +19 -21
  33. package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
  34. package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
  35. package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
  36. package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
  37. package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
  38. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +168 -39
  39. package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
  40. package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
  41. package/templates/apps/api/src/server.ts +49 -16
  42. package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
  43. package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
  44. package/templates/apps/api/src/vercel-handler.ts +58 -12
  45. package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
  46. package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
  47. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
  48. package/templates/apps/web/.env.example +4 -0
  49. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
  50. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
  51. package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
  52. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
  53. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
  54. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
  55. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
  56. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
  57. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
  58. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +297 -0
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
  65. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +143 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
  67. package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
  69. package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
  70. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +88 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
  72. package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
  78. package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
  80. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
  81. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
  82. package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
  83. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
  84. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
  85. package/templates/apps/web/app/[locale]/layout.tsx +98 -84
  86. package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
  87. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
  88. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
  89. package/templates/apps/web/app/openapi.json/route.ts +127 -0
  90. package/templates/apps/web/auth.ts +102 -18
  91. package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
  92. package/templates/apps/web/components/signed-in-nav.tsx +58 -0
  93. package/templates/apps/web/jest.config.cjs +5 -0
  94. package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
  95. package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
  96. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
  97. package/templates/apps/web/lib/account-config.ts +219 -11
  98. package/templates/apps/web/lib/auth-email.ts +104 -2
  99. package/templates/apps/web/lib/auth-env.ts +37 -5
  100. package/templates/apps/web/lib/auth-routes.ts +33 -4
  101. package/templates/apps/web/messages/en.json +147 -21
  102. package/templates/database/CHANGELOG.md +7 -0
  103. package/templates/database/package.json +1 -1
  104. package/versions.json +33 -33
  105. package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
@@ -16,6 +16,9 @@
16
16
  * default) instead of a hardcoded vendor-brand literal.
17
17
  * Issue #7136 — the five `feature`-tagged tools are gated on the licence
18
18
  * features the route passes in (`heldFeatures`), as registration is.
19
+ * Issue #7396 — `mcpEndpoint` is the configured MCP HTTP host or absent. It
20
+ * was `${baseUrl}/api/mcp`, a path apps/web does not serve, so every agent
21
+ * that followed it got a 404.
19
22
  */
20
23
 
21
24
  import {
@@ -44,8 +47,12 @@ export interface McpManifest {
44
47
  name: string;
45
48
  /** Human-readable server description. */
46
49
  description: string;
47
- /** Absolute URL the agent connects to for the MCP protocol session. */
48
- mcpEndpoint: string;
50
+ /**
51
+ * Absolute URL the agent connects to for the MCP protocol session — the
52
+ * standalone MCP HTTP host. Omitted when none is configured: the web host
53
+ * serves no MCP endpoint of its own (#7396).
54
+ */
55
+ mcpEndpoint?: string;
49
56
  /** Tool catalog — names, descriptions, input schemas, and output schemas. */
50
57
  tools: McpToolCatalogEntry[];
51
58
  /**
@@ -69,14 +76,13 @@ export interface McpManifest {
69
76
  }
70
77
 
71
78
  export interface BuildMcpManifestOptions {
72
- /** Site base URL — used to derive the MCP endpoint. */
73
- baseUrl: string;
74
79
  /** AILK package version surfaced to discovering agents. */
75
80
  ailkVersion: string;
76
81
  /**
77
82
  * Optional: absolute URL of the standalone MCP HTTP transport
78
83
  * (i.e. the URL of apps/mcp when running in HTTP mode).
79
- * When set, the manifest advertises this as an additional transport endpoint.
84
+ * When set, the manifest advertises it as `mcpEndpoint` and as an http
85
+ * transport descriptor; when not, neither appears.
80
86
  * Typically sourced from the MCP_PUBLIC_HTTP_URL env var in the web app.
81
87
  */
82
88
  mcpHttpUrl?: string;
@@ -103,9 +109,6 @@ export interface BuildMcpManifestOptions {
103
109
  featureGate?: McpCatalogOptions["featureGate"];
104
110
  }
105
111
 
106
- /** Path the MCP server is mounted at, relative to baseUrl. */
107
- const MCP_ENDPOINT_PATH = "/api/mcp";
108
-
109
112
  /** MCP HTTP transport version this server implements. */
110
113
  const MCP_HTTP_VERSION = "2025-03-26";
111
114
 
@@ -131,7 +134,9 @@ export function buildMcpManifest(opts: BuildMcpManifestOptions): McpManifest {
131
134
  name: SITE_NAME,
132
135
  description:
133
136
  "AX-first OSS template — ship software with API + MCP as canonical agent surfaces.",
134
- mcpEndpoint: `${opts.baseUrl}${MCP_ENDPOINT_PATH}`,
137
+ // Spread conditional to satisfy exactOptionalPropertyTypes: the key is
138
+ // absent, not undefined, when no MCP HTTP host is configured.
139
+ ...(opts.mcpHttpUrl ? { mcpEndpoint: opts.mcpHttpUrl } : {}),
135
140
  tools: getMcpToolCatalog({
136
141
  addons: { commerce: opts.commerceClaimed === true },
137
142
  features: opts.heldFeatures ?? [],
@@ -4,6 +4,8 @@
4
4
  * Per-site MCP discovery manifest. Agents land on a customer site, hit this
5
5
  * URL, and discover the MCP endpoint + tool catalog without out-of-band
6
6
  * coordination. Issue #209 (initial), #300 (moved to correct URL + CORS).
7
+ * The endpoint is advertised only when MCP_PUBLIC_HTTP_URL configures one
8
+ * (#7396) — apps/web serves no MCP endpoint itself.
7
9
  *
8
10
  * Assembly — no fetch(). The catalog is read in-process from
9
11
  * @working-theory/metadata; the AILK version is read from this app's
@@ -29,7 +31,6 @@ import {
29
31
  COMMERCE_FEATURE,
30
32
  resolveLicenceFeatures,
31
33
  } from "../../../src/lib/commerce-claim";
32
- import { resolveBaseUrl } from "../../../src/lib/routes";
33
34
 
34
35
  import { buildMcpManifest } from "./build-manifest";
35
36
 
@@ -48,13 +49,13 @@ export function OPTIONS(): Response {
48
49
 
49
50
  export async function GET(): Promise<Response> {
50
51
  // MCP_PUBLIC_HTTP_URL advertises the standalone HTTP transport endpoint
51
- // (apps/mcp running in HTTP mode). When set, the manifest includes it
52
- // as an additional transport descriptor for discovering agents.
52
+ // (apps/mcp running in HTTP mode). When set, the manifest names it as
53
+ // `mcpEndpoint` and as an http transport descriptor; when unset, it
54
+ // advertises no MCP URL at all (#7396).
53
55
  const mcpHttpUrlEnv = process.env["MCP_PUBLIC_HTTP_URL"];
54
56
  const heldFeatures = await resolveLicenceFeatures();
55
57
 
56
58
  const manifest = buildMcpManifest({
57
- baseUrl: resolveBaseUrl(),
58
59
  ailkVersion: packageJson.version,
59
60
  commerceClaimed: heldFeatures.includes(COMMERCE_FEATURE),
60
61
  heldFeatures,
@@ -62,6 +62,8 @@ jest.mock("better-auth/next-js", () => ({
62
62
  nextCookies: () => ({ id: "next-cookies" }),
63
63
  }));
64
64
 
65
+ jest.mock("better-auth/plugins", () => ({ magicLink: () => ({ id: "magic-link" }) }));
66
+
65
67
  /**
66
68
  * The signer seam. `buildAuthEmailRequest` has exactly one external
67
69
  * dependency — `node:crypto`'s `createHmac` — so a throwing `createHmac` is a
@@ -18,6 +18,9 @@
18
18
  * (#6018 AC-1). (#6068 repointed that predicate from `isResetAvailable()`
19
19
  * to `resetSendConfigured()`; the mock below binds both to one fn, so
20
20
  * these cases read identically either way.)
21
+ * (h) the email sign-in link (#7435): with "magic-link" active the form
22
+ * renders beside the providers (or alone); a `?error=` from a failed
23
+ * link maps to fixed copy and is never echoed.
21
24
  *
22
25
  * `next-intl/server` is mocked globally (apps/web/__tests__/setup.ts) against
23
26
  * the real en.json, so `signIn` copy is asserted by its real strings.
@@ -58,10 +61,13 @@ jest.mock("better-auth/react", () => ({
58
61
  import en from "../../../../../messages/en.json";
59
62
  import Page, { generateMetadata } from "../page";
60
63
 
61
- async function renderPage(next?: string): Promise<void> {
64
+ async function renderPage(next?: string, error?: string): Promise<void> {
62
65
  const el = await Page({
63
66
  params: Promise.resolve({ locale: "en" }),
64
- searchParams: Promise.resolve(next === undefined ? {} : { next }),
67
+ searchParams: Promise.resolve({
68
+ ...(next === undefined ? {} : { next }),
69
+ ...(error === undefined ? {} : { error }),
70
+ }),
65
71
  });
66
72
  render(el);
67
73
  }
@@ -196,3 +202,64 @@ describe("/sign-in page", () => {
196
202
  expect(screen.queryByTestId("auth-forgot")).toBeNull();
197
203
  });
198
204
  });
205
+
206
+ describe("/sign-in page — the email sign-in link (#7435)", () => {
207
+ it("(h) renders no email-link form when 'magic-link' is not active", async () => {
208
+ mockGetActiveProviderIds.mockReturnValue(["google", "email"]);
209
+ await renderPage("/account");
210
+ expect(screen.queryByTestId("auth-email-link-form")).toBeNull();
211
+ });
212
+
213
+ it("(h) with google + magic-link: the Google button and 'Email me a sign-in link'", async () => {
214
+ mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
215
+ await renderPage("/account");
216
+ expect(screen.getByTestId("auth-sso-google")).toBeInTheDocument();
217
+ expect(screen.getByTestId("auth-email-link-submit")).toHaveTextContent(
218
+ en.signIn.emailLinkSubmit,
219
+ );
220
+ expect(en.signIn.emailLinkSubmit).toBe("Email me a sign-in link");
221
+ expect(screen.getByTestId("auth-email-link-divider")).toBeInTheDocument();
222
+ });
223
+
224
+ it("(h) with magic-link only: the form renders and the not-configured subhead does not", async () => {
225
+ mockGetActiveProviderIds.mockReturnValue(["magic-link"]);
226
+ await renderPage();
227
+ expect(screen.getByTestId("auth-email-link-form")).toBeInTheDocument();
228
+ expect(screen.queryByText(en.signIn.notConfiguredSubhead)).toBeNull();
229
+ expect(screen.getByText(en.signIn.subhead)).toBeInTheDocument();
230
+ });
231
+
232
+ it("(h) beside the password form when the password method is active too", async () => {
233
+ mockGetActiveProviderIds.mockReturnValue(["google", "email", "magic-link"]);
234
+ await renderPage("/account");
235
+ expect(screen.getByTestId("auth-password")).toBeInTheDocument();
236
+ expect(screen.getByTestId("auth-email-link-form")).toBeInTheDocument();
237
+ });
238
+
239
+ it.each(["INVALID_TOKEN", "new_user_signup_disabled"])(
240
+ "(h) ?error=%s renders the expired-link notice",
241
+ async (error) => {
242
+ mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
243
+ await renderPage("/account", error);
244
+ expect(screen.getByTestId("sign-in-link-notice")).toHaveTextContent(
245
+ en.signIn.emailLinkExpired,
246
+ );
247
+ },
248
+ );
249
+
250
+ it("(h) any other ?error renders signInFailed and never echoes the value", async () => {
251
+ mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
252
+ await renderPage("/account", "<script>alert(1)</script>");
253
+ expect(screen.getByTestId("sign-in-link-notice")).toHaveTextContent(
254
+ en.signIn.signInFailed,
255
+ );
256
+ expect(document.body.innerHTML).not.toContain("alert(1)");
257
+ expect(document.body.innerHTML).not.toContain("&lt;script");
258
+ });
259
+
260
+ it("(h) no ?error → no notice", async () => {
261
+ mockGetActiveProviderIds.mockReturnValue(["google", "magic-link"]);
262
+ await renderPage("/account");
263
+ expect(screen.queryByTestId("sign-in-link-notice")).toBeNull();
264
+ });
265
+ });
@@ -0,0 +1,156 @@
1
+ /**
2
+ * Tests for `SignInCard`'s email sign-in link flow (#7435 § D8, AC-5).
3
+ *
4
+ * The card POSTs to Better Auth's `/sign-in/magic-link` through the shared
5
+ * client's `$fetch` (not the `magicLinkClient()` plugin, so no ESM-only module
6
+ * enters the client graph). Pinned here:
7
+ * - the request carries the typed email, `callbackURL` = `next`, and the
8
+ * `errorCallbackURL`;
9
+ * - a success shows the neutral sent state — never the address, never
10
+ * AuthCard's English "Follow the link we just sent" default;
11
+ * - a 429 lands `rateLimited` on the field; any other error, or a
12
+ * rejection, lands `failed`;
13
+ * - with `emailLink` false the card renders as before.
14
+ */
15
+ import { render, screen } from "@testing-library/react";
16
+ import userEvent from "@testing-library/user-event";
17
+
18
+ const mockFetch = jest.fn();
19
+ jest.mock("better-auth/react", () => ({
20
+ __esModule: true,
21
+ createAuthClient: () => ({
22
+ signIn: { social: jest.fn(), email: jest.fn() },
23
+ $fetch: (...args: unknown[]) => mockFetch(...args),
24
+ }),
25
+ }));
26
+
27
+ import { SignInCard, type SignInCardProps } from "../sign-in-card";
28
+
29
+ const EMAIL_LINK_LABELS = {
30
+ sentHeading: "Check your email",
31
+ sentSubhead: "Look for a message from this site.",
32
+ sentBody: "If an account uses that address, a sign-in link is on its way.",
33
+ backToSignIn: "Back to sign in",
34
+ failed: "We couldn't send a sign-in link. Try again.",
35
+ rateLimited: "Too many requests. Wait a minute, then try again.",
36
+ };
37
+
38
+ function props(overrides: Partial<SignInCardProps> = {}): SignInCardProps {
39
+ return {
40
+ providers: ["google"],
41
+ method: "sso",
42
+ next: "/account",
43
+ labels: { emailLinkSubmit: "Email me a sign-in link" },
44
+ notConfiguredSubhead: "Sign-in is not configured.",
45
+ errorLabels: { signInFailed: "Sign-in failed.", providerFailed: "Nope." },
46
+ emailLink: true,
47
+ errorCallbackURL: "/sign-in?next=%2Faccount",
48
+ signInHref: "/sign-in?next=%2Faccount",
49
+ emailLinkLabels: EMAIL_LINK_LABELS,
50
+ ...overrides,
51
+ };
52
+ }
53
+
54
+ async function requestLink(email = "member@example.com"): Promise<void> {
55
+ const user = userEvent.setup();
56
+ await user.type(screen.getByTestId("auth-email-link-input"), email);
57
+ await user.click(screen.getByTestId("auth-email-link-submit"));
58
+ }
59
+
60
+ beforeEach(() => {
61
+ mockFetch.mockReset().mockResolvedValue({ data: { status: true }, error: null });
62
+ });
63
+
64
+ describe("SignInCard — email sign-in link (#7435)", () => {
65
+ it("POSTs /sign-in/magic-link with the email, next and the errorCallbackURL", async () => {
66
+ render(<SignInCard {...props()} />);
67
+ await requestLink();
68
+ expect(mockFetch).toHaveBeenCalledTimes(1);
69
+ expect(mockFetch).toHaveBeenCalledWith("/sign-in/magic-link", {
70
+ method: "POST",
71
+ body: {
72
+ email: "member@example.com",
73
+ callbackURL: "/account",
74
+ errorCallbackURL: "/sign-in?next=%2Faccount",
75
+ },
76
+ });
77
+ });
78
+
79
+ it("shows the neutral sent state on success — no address, no AuthCard defaults", async () => {
80
+ render(<SignInCard {...props()} />);
81
+ await requestLink();
82
+ expect(
83
+ await screen.findByText(EMAIL_LINK_LABELS.sentHeading),
84
+ ).toBeInTheDocument();
85
+ expect(screen.getByText(EMAIL_LINK_LABELS.sentSubhead)).toBeInTheDocument();
86
+ expect(screen.getByText(EMAIL_LINK_LABELS.sentBody)).toBeInTheDocument();
87
+ const back = screen.getByTestId("auth-footer-link");
88
+ expect(back).toHaveTextContent(EMAIL_LINK_LABELS.backToSignIn);
89
+ expect(back).toHaveAttribute("href", "/sign-in?next=%2Faccount");
90
+ expect(document.body.textContent).not.toContain("member@example.com");
91
+ expect(document.body.textContent).not.toContain("Follow the link we just sent");
92
+ expect(screen.queryByTestId("auth-email-link-form")).toBeNull();
93
+ });
94
+
95
+ it("lands rateLimited on the field for a 429", async () => {
96
+ mockFetch.mockResolvedValue({
97
+ data: null,
98
+ error: { status: 429, statusText: "Too Many Requests" },
99
+ });
100
+ render(<SignInCard {...props()} />);
101
+ await requestLink();
102
+ expect(
103
+ await screen.findByText(EMAIL_LINK_LABELS.rateLimited),
104
+ ).toBeInTheDocument();
105
+ expect(screen.getByTestId("auth-email-link-form")).toBeInTheDocument();
106
+ });
107
+
108
+ it("lands failed on the field for any other error", async () => {
109
+ mockFetch.mockResolvedValue({
110
+ data: null,
111
+ error: { status: 400, statusText: "Bad Request" },
112
+ });
113
+ render(<SignInCard {...props()} />);
114
+ await requestLink();
115
+ expect(await screen.findByText(EMAIL_LINK_LABELS.failed)).toBeInTheDocument();
116
+ });
117
+
118
+ it("lands failed on the field when the request rejects", async () => {
119
+ mockFetch.mockRejectedValue(new Error("network"));
120
+ render(<SignInCard {...props()} />);
121
+ await requestLink();
122
+ expect(await screen.findByText(EMAIL_LINK_LABELS.failed)).toBeInTheDocument();
123
+ });
124
+
125
+ it("sends one request for a double submit while the first is in flight", async () => {
126
+ let resolve!: (v: unknown) => void;
127
+ mockFetch.mockReturnValue(new Promise((r) => (resolve = r)));
128
+ const user = userEvent.setup();
129
+ render(<SignInCard {...props()} />);
130
+ await user.type(screen.getByTestId("auth-email-link-input"), "member@example.com");
131
+ await user.click(screen.getByTestId("auth-email-link-submit"));
132
+ await user.click(screen.getByTestId("auth-email-link-submit"));
133
+ expect(mockFetch).toHaveBeenCalledTimes(1);
134
+ resolve({ data: { status: true }, error: null });
135
+ expect(await screen.findByText(EMAIL_LINK_LABELS.sentHeading)).toBeInTheDocument();
136
+ });
137
+
138
+ it("renders the link notice above the card when given one", () => {
139
+ render(<SignInCard {...props({ linkNotice: "That link expired." })} />);
140
+ expect(screen.getByTestId("sign-in-link-notice")).toHaveTextContent(
141
+ "That link expired.",
142
+ );
143
+ });
144
+
145
+ it("renders the form alone, without the not-configured subhead, when it is the only method", () => {
146
+ render(<SignInCard {...props({ providers: [] })} />);
147
+ expect(screen.getByTestId("auth-email-link-form")).toBeInTheDocument();
148
+ expect(screen.queryByText("Sign-in is not configured.")).toBeNull();
149
+ });
150
+
151
+ it("renders no email-link form when emailLink is false", () => {
152
+ render(<SignInCard {...props({ emailLink: false, providers: [] })} />);
153
+ expect(screen.queryByTestId("auth-email-link-form")).toBeNull();
154
+ expect(screen.getByText("Sign-in is not configured.")).toBeInTheDocument();
155
+ });
156
+ });
@@ -6,6 +6,11 @@
6
6
  * `?next=` return path, and the translated `signIn` labels — then renders the
7
7
  * `SignInCard` client island.
8
8
  *
9
+ * Email sign-in link (#7435 § D9): offered when auth-env reports
10
+ * "magic-link" — the predicate `auth.ts` registers the plugin on. A link that
11
+ * fails verification lands back here with Better Auth's `?error=`, which maps
12
+ * to FIXED copy; the raw value is never rendered.
13
+ *
9
14
  * Since #5963 the page lives in the `(auth)` route group — the URL is
10
15
  * unchanged (a group contributes no path segment) but the frame is
11
16
  * `AuthShell`, not the marketing Nav + Footer. The shell owns the brand mark
@@ -17,7 +22,6 @@
17
22
  */
18
23
 
19
24
  import { localeHref } from "@working-theory/ui";
20
- import type { AuthCardLabels } from "@working-theory/ui/blocks";
21
25
  import type { Metadata } from "next";
22
26
  import { getTranslations } from "next-intl/server";
23
27
  import type { ReactElement } from "react";
@@ -29,10 +33,32 @@ import {
29
33
 
30
34
  import { resolveNextPath } from "./next-path";
31
35
  import { SignInCard, type SignInProvider } from "./sign-in-card";
36
+ import { resolveSignInCardText, type SignInCardText } from "./sign-in-text";
32
37
 
33
38
  interface Props {
34
39
  params: Promise<{ locale: string }>;
35
- searchParams: Promise<{ next?: string | string[] }>;
40
+ searchParams: Promise<{ next?: string | string[]; error?: string | string[] }>;
41
+ }
42
+
43
+ /**
44
+ * Better Auth's magic-link verify redirects a failed link here with
45
+ * `?error=<code>`. Expired / used / account-gone map to the expired copy;
46
+ * anything else to the generic failure. Never the value itself.
47
+ */
48
+ const EXPIRED_LINK_ERRORS: ReadonlySet<string> = new Set([
49
+ "INVALID_TOKEN",
50
+ "new_user_signup_disabled",
51
+ ]);
52
+
53
+ function linkNoticeFor(
54
+ error: string | string[] | undefined,
55
+ text: SignInCardText,
56
+ ): string | undefined {
57
+ if (error === undefined) return undefined;
58
+ const code = Array.isArray(error) ? error[0] : error;
59
+ return code !== undefined && EXPIRED_LINK_ERRORS.has(code)
60
+ ? text.emailLinkExpired
61
+ : text.errorLabels.signInFailed;
36
62
  }
37
63
 
38
64
  export async function generateMetadata({ params }: Props): Promise<Metadata> {
@@ -46,7 +72,8 @@ export default async function SignInPage({
46
72
  searchParams,
47
73
  }: Props): Promise<ReactElement> {
48
74
  const { locale } = await params;
49
- const t = await getTranslations({ locale, namespace: "signIn" });
75
+ const { next: rawNext, error } = await searchParams;
76
+ const text = await resolveSignInCardText(locale);
50
77
 
51
78
  const active = getActiveProviderIds();
52
79
  // Exactly the OAuth providers auth-env reports — never a default set.
@@ -54,23 +81,12 @@ export default async function SignInPage({
54
81
  (id): id is SignInProvider => id === "google" || id === "github",
55
82
  );
56
83
  const method = active.includes("email") ? "password" : "sso";
57
- const next = resolveNextPath(
58
- (await searchParams).next,
59
- localeHref(locale, "/account"),
60
- );
61
-
62
- const labels: AuthCardLabels = {
63
- signInHeading: t("heading"),
64
- signInSubhead: t("subhead"),
65
- ssoPrefix: t("ssoPrefix"),
66
- google: t("google"),
67
- github: t("github"),
68
- email: t("email"),
69
- password: t("password"),
70
- forgot: t("forgot"),
71
- signInSubmit: t("submit"),
72
- orContinue: t("orContinue"),
73
- };
84
+ const emailLink = active.includes("magic-link");
85
+ const next = resolveNextPath(rawNext, localeHref(locale, "/account"));
86
+ // A failed link returns here, keeping the visitor's destination.
87
+ const signInHref = `${localeHref(locale, "/sign-in")}?next=${encodeURIComponent(next)}`;
88
+ // Only the email link produces `?error=` here; with it off, today's render.
89
+ const linkNotice = emailLink ? linkNoticeFor(error, text) : undefined;
74
90
 
75
91
  return (
76
92
  <SignInCard
@@ -86,12 +102,14 @@ export default async function SignInPage({
86
102
  // send conjunction, so the flag alone would offer a link to a page
87
103
  // rendering its own not-available card (#6068 AC-2).
88
104
  forgot={resetSendConfigured() ? "link" : "none"}
89
- labels={labels}
90
- notConfiguredSubhead={t("notConfiguredSubhead")}
91
- errorLabels={{
92
- signInFailed: t("signInFailed"),
93
- providerFailed: t("providerFailed"),
94
- }}
105
+ labels={text.labels}
106
+ notConfiguredSubhead={text.notConfiguredSubhead}
107
+ errorLabels={text.errorLabels}
108
+ emailLink={emailLink}
109
+ errorCallbackURL={signInHref}
110
+ signInHref={signInHref}
111
+ {...(linkNotice !== undefined ? { linkNotice } : {})}
112
+ emailLinkLabels={text.emailLinkLabels}
95
113
  />
96
114
  );
97
115
  }
@@ -1,4 +1,4 @@
1
- "use client"; // Better Auth's client calls (signIn.social / signIn.email) + error state
1
+ "use client"; // Better Auth's client calls (signIn.social / signIn.email / $fetch) + error state
2
2
 
3
3
  /**
4
4
  * SignInCard — the /sign-in page's client island (#4984).
@@ -36,6 +36,15 @@
36
36
  * `forgot` (#6018 AC-1): the page passes `forgot="none"` when
37
37
  * `isResetAvailable()` is false, so the "Forgot password?" link never renders
38
38
  * a dead end — AuthCard's own default (`"link"`) assumes reset is wired.
39
+ *
40
+ * Email sign-in link (#7435 § D8): with `emailLink` true the card adds
41
+ * AuthCard's `emailLink="form"` and POSTs the typed address to Better Auth's
42
+ * `/sign-in/magic-link` through the shared client's `$fetch` — not the
43
+ * `magicLinkClient()` plugin, so no ESM-only module enters the client graph
44
+ * every `(auth)` and `(authed)` suite would then have to mock. The answer is
45
+ * the same for an address with and without an account, so a success swaps to
46
+ * a sent state that never names the address. The gate composes this card too
47
+ * (`(authed)/sign-in-gate.tsx`).
39
48
  */
40
49
 
41
50
  import {
@@ -44,13 +53,25 @@ import {
44
53
  type AuthSubmitData,
45
54
  } from "@working-theory/ui/blocks";
46
55
  import { Alert, AlertDescription } from "@working-theory/ui/primitives";
47
- import type { ReactElement } from "react";
56
+ import { useRef, useState, type ReactElement } from "react";
48
57
 
49
58
  import { authClient, useAuthCardFlow } from "../use-auth-card-flow";
50
59
 
51
60
  /** The OAuth providers this page can launch — a subset of AuthCard's. */
52
61
  export type SignInProvider = "google" | "github";
53
62
 
63
+ /** Translated copy for the email sign-in link flow (#7435 § D10). */
64
+ export interface SignInEmailLinkLabels {
65
+ sentHeading: string;
66
+ sentSubhead: string;
67
+ sentBody: string;
68
+ backToSignIn: string;
69
+ /** Any failed request other than a rate limit. */
70
+ failed: string;
71
+ /** A 429 from Better Auth's per-IP limit. */
72
+ rateLimited: string;
73
+ }
74
+
54
75
  export interface SignInCardProps {
55
76
  /** Active OAuth providers, exactly as auth-env reports them. */
56
77
  providers: SignInProvider[];
@@ -68,6 +89,15 @@ export interface SignInCardProps {
68
89
  notConfiguredSubhead: string;
69
90
  /** Translated error copy. */
70
91
  errorLabels: { signInFailed: string; providerFailed: string };
92
+ /** Offer "Email me a sign-in link" — `getActiveProviderIds()` has "magic-link". */
93
+ emailLink: boolean;
94
+ /** Where a failed link lands (`/sign-in?next=…`); Better Auth appends `?error`. */
95
+ errorCallbackURL: string;
96
+ /** The sent state's "Back to sign in" link. */
97
+ signInHref: string;
98
+ /** Fixed copy for a failed link (`?error=` mapped by the page), shown above the card. */
99
+ linkNotice?: string;
100
+ emailLinkLabels: SignInEmailLinkLabels;
71
101
  }
72
102
 
73
103
  export function SignInCard({
@@ -79,14 +109,25 @@ export function SignInCard({
79
109
  labels,
80
110
  notConfiguredSubhead,
81
111
  errorLabels,
112
+ emailLink,
113
+ errorCallbackURL,
114
+ signInHref,
115
+ linkNotice,
116
+ emailLinkLabels,
82
117
  }: SignInCardProps): ReactElement {
83
118
  const { errors, providerError, launchProvider, submitPassword } =
84
119
  useAuthCardFlow(next, {
85
120
  providerFailed: errorLabels.providerFailed,
86
121
  passwordFailed: errorLabels.signInFailed,
87
122
  });
123
+ const [emailLinkSent, setEmailLinkSent] = useState(false);
124
+ const [emailLinkError, setEmailLinkError] = useState<string | null>(null);
125
+ // One request at a time: a double-click would spend two of the per-IP and
126
+ // per-address sends and mint two live tokens (code review).
127
+ const emailLinkPending = useRef(false);
88
128
 
89
- const notConfigured = method === "sso" && providers.length === 0;
129
+ const notConfigured =
130
+ method === "sso" && providers.length === 0 && !emailLink;
90
131
 
91
132
  const providerButtons = providers.map((provider) => ({
92
133
  provider,
@@ -103,6 +144,52 @@ export function SignInCard({
103
144
  );
104
145
  };
105
146
 
147
+ const requestEmailLink = async (email: string): Promise<void> => {
148
+ if (emailLinkPending.current) return;
149
+ emailLinkPending.current = true;
150
+ setEmailLinkError(null);
151
+ try {
152
+ const { error } = await authClient.$fetch("/sign-in/magic-link", {
153
+ method: "POST",
154
+ body: { email, callbackURL: next, errorCallbackURL },
155
+ });
156
+ if (!error) {
157
+ setEmailLinkSent(true);
158
+ return;
159
+ }
160
+ setEmailLinkError(
161
+ error.status === 429
162
+ ? emailLinkLabels.rateLimited
163
+ : emailLinkLabels.failed,
164
+ );
165
+ } catch {
166
+ setEmailLinkError(emailLinkLabels.failed);
167
+ } finally {
168
+ emailLinkPending.current = false;
169
+ }
170
+ };
171
+
172
+ if (emailLinkSent) {
173
+ // Every string passed in, so no AuthCard English default — whose "Follow
174
+ // the link we just sent" would contradict the neutral copy — renders. No
175
+ // `sentTo`: the state must not confirm the address has an account.
176
+ return (
177
+ <div className="flex flex-col gap-4" data-testid="sign-in-page">
178
+ <AuthCard
179
+ intent="sent"
180
+ footer="cross-link"
181
+ signInHref={signInHref}
182
+ labels={{
183
+ sentHeading: emailLinkLabels.sentHeading,
184
+ sentSubhead: emailLinkLabels.sentSubhead,
185
+ sentBody: emailLinkLabels.sentBody,
186
+ backToSignIn: emailLinkLabels.backToSignIn,
187
+ }}
188
+ />
189
+ </div>
190
+ );
191
+ }
192
+
106
193
  return (
107
194
  /*
108
195
  * No page framing of its own (#5963): AuthShell owns the brand row, the
@@ -116,6 +203,11 @@ export function SignInCard({
116
203
  <AlertDescription>{providerError}</AlertDescription>
117
204
  </Alert>
118
205
  ) : null}
206
+ {linkNotice ? (
207
+ <Alert variant="error" data-testid="sign-in-link-notice">
208
+ <AlertDescription>{linkNotice}</AlertDescription>
209
+ </Alert>
210
+ ) : null}
119
211
  <AuthCard
120
212
  intent="sign-in"
121
213
  method={method}
@@ -125,8 +217,16 @@ export function SignInCard({
125
217
  {...(notConfigured ? { subhead: notConfiguredSubhead } : {})}
126
218
  providers={providerButtons}
127
219
  onSubmit={handleSubmit}
128
- errors={errors}
220
+ errors={
221
+ emailLinkError ? { ...errors, emailLink: emailLinkError } : errors
222
+ }
129
223
  labels={labels}
224
+ {...(emailLink
225
+ ? {
226
+ emailLink: "form" as const,
227
+ onEmailLink: (email: string) => void requestEmailLink(email),
228
+ }
229
+ : {})}
130
230
  />
131
231
  </div>
132
232
  );