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.
- package/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +41 -28
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +29 -5
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +148 -21
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* - logo_url: no asset yet; omitted
|
|
10
10
|
* - contact_email: no org email configured; omitted
|
|
11
11
|
* - legal_info_url: no legal page yet; omitted
|
|
12
|
-
* - api.url:
|
|
12
|
+
* - api.url: /openapi.json on this same origin, served by `app/openapi.json`,
|
|
13
|
+
* which proxies apps/api's `/v1/openapi.json` (#7396)
|
|
13
14
|
*
|
|
14
15
|
* ─── Consumer re-validation expectation ─────────────────────────────────────
|
|
15
16
|
* This manifest is published over a public, cacheable HTTP route. Consumers
|
|
@@ -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
|
-
/**
|
|
48
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
52
|
-
// as an
|
|
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(
|
|
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("<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
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
)
|
|
61
|
-
|
|
62
|
-
const
|
|
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={
|
|
91
|
-
errorLabels={
|
|
92
|
-
|
|
93
|
-
|
|
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
|
|
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 =
|
|
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={
|
|
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
|
);
|