create-ailk 0.11.2 → 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.
- 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 +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- 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 +143 -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 +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- 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 +147 -21
- package/templates/database/CHANGELOG.md +7 -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
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
133
|
-
render(
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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
|
+
});
|