create-ailk 0.19.0 → 0.19.1

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 (91) hide show
  1. package/dist/add-module.js +7 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  5. package/dist/lib/scaffold-render-sweep.js +52 -0
  6. package/dist/module-architecture.js +9 -3
  7. package/dist/programmatic.js +8 -1
  8. package/dist/site-roster.js +21 -4
  9. package/dist/surfaces.d.ts +14 -0
  10. package/dist/surfaces.js +31 -1
  11. package/dist/topology.js +29 -1
  12. package/package.json +3 -3
  13. package/templates/apps/api/.env.example +17 -1
  14. package/templates/apps/api/CLAUDE.md +10 -4
  15. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  16. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  18. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  19. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  20. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  21. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  22. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  23. package/templates/apps/api/src/lib/stripe.ts +97 -5
  24. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
  25. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  26. package/templates/apps/api/src/middleware/auth.ts +11 -3
  27. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  28. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  29. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  30. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  31. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  32. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  33. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  34. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  35. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  36. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  37. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  38. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  39. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  40. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  41. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  42. package/templates/apps/api/src/server.ts +111 -16
  43. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  44. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  45. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  46. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  47. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  48. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  49. package/templates/apps/api/src/services/consent.ts +488 -3
  50. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  51. package/templates/apps/api/src/services/person.ts +501 -1
  52. package/templates/apps/api/src/services/project-listings.ts +7 -2
  53. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  54. package/templates/apps/api/src/services/revalidation.ts +11 -1
  55. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  57. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
  58. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  65. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
  66. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  67. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
  69. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  70. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
  71. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  72. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
  75. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  76. package/templates/apps/web/auth.ts +20 -1
  77. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  78. package/templates/apps/web/lib/auth-email.ts +45 -10
  79. package/templates/apps/web/lib/topology.generated.ts +9 -0
  80. package/templates/apps/web/messages/en.json +54 -14
  81. package/templates/database/CHANGELOG.md +16 -0
  82. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  83. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  84. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  85. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  86. package/templates/database/__tests__/person.db.test.ts +3 -1
  87. package/templates/database/inbox/schema.prisma +76 -10
  88. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  89. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  90. package/templates/database/package.json +1 -1
  91. package/versions.json +35 -35
@@ -1,15 +1,23 @@
1
1
  /**
2
2
  * Tests for /[locale]/account/security — User settings › Security (#8339
3
- * D8, NEW). Moved from general.test.tsx's "Sign-in" cases (#8339 D12):
4
- * the frame with Security current, and `signInMethod()`'s three branches
5
- * (#8232 D7) — configured methods, none, and (hub-mode.test.tsx) the hub
6
- * line.
3
+ * D8; #8431 D2, D3, AC-2, AC-3, AC-4).
4
+ *
5
+ * - the frame with Security current (#8339);
6
+ * - AC-2: a site or hub that can email a password link shows the Password
7
+ * row from the session's own accounts, and its button requests a link
8
+ * for the person's own address with the server-built `redirectTo`; a
9
+ * seam rendered before #8431 degrades to the sentence;
10
+ * - AC-3: any other site shows one sentence naming the deployment's
11
+ * methods, never a provider list;
12
+ * - AC-4 (site half): Log out signs out through Better Auth and lands on
13
+ * the sign-in page, and a failed log-out navigates nowhere. The spoke
14
+ * half is hub-mode.test.tsx's.
7
15
  */
8
16
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
9
17
  import { tmpdir } from "node:os";
10
18
  import { join } from "node:path";
11
19
 
12
- import { render, screen, within } from "@testing-library/react";
20
+ import { act, fireEvent, render, screen, within } from "@testing-library/react";
13
21
 
14
22
  jest.mock("next-intl/server", () => {
15
23
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -41,12 +49,32 @@ jest.mock("next/headers", () => ({
41
49
  }));
42
50
 
43
51
  const mockGetSession = jest.fn();
52
+ const mockListUserAccounts = jest.fn();
44
53
  // 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.
45
54
  jest.mock("../../../../../auth", () => ({
46
55
  __esModule: true,
47
- auth: { api: { getSession: (...args: unknown[]) => mockGetSession(...args) } },
56
+ auth: {
57
+ api: {
58
+ getSession: (...args: unknown[]) => mockGetSession(...args),
59
+ listUserAccounts: (...args: unknown[]) => mockListUserAccounts(...args),
60
+ },
61
+ },
48
62
  }));
49
63
 
64
+ /** `stale: true` serves the seam as one rendered before #8431 — no accounts reader (D2b). */
65
+ const mockSeam = { stale: false };
66
+ // eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); a seam rendered before #8431 (D2b) is the boundary under test, and no env or HTTP surface selects it.
67
+ jest.mock("../../../../../lib/topology.generated", () => {
68
+ const real = jest.requireActual("../../../../../lib/topology.generated") as Record<string, unknown>;
69
+ return {
70
+ ...real,
71
+ __esModule: true,
72
+ get readOwnAccounts(): unknown {
73
+ return mockSeam.stale ? undefined : real.readOwnAccounts;
74
+ },
75
+ };
76
+ });
77
+
50
78
  jest.mock("@working-theory/api-client", () => ({
51
79
  __esModule: true,
52
80
  waitlistSignups: { me: jest.fn() },
@@ -58,9 +86,21 @@ jest.mock("@working-theory/api-client", () => ({
58
86
  visitorForwardHeaders: () => null,
59
87
  }));
60
88
 
89
+ const mockRequestPasswordReset = jest.fn();
90
+ const mockSignOut = jest.fn();
91
+ jest.mock("better-auth/react", () => ({
92
+ __esModule: true,
93
+ createAuthClient: () => ({
94
+ requestPasswordReset: (...args: unknown[]) => mockRequestPasswordReset(...args),
95
+ signOut: (...args: unknown[]) => mockSignOut(...args),
96
+ }),
97
+ }));
98
+
99
+ const mockPush = jest.fn();
100
+ const mockRefresh = jest.fn();
61
101
  jest.mock("next/navigation", () => ({
62
102
  __esModule: true,
63
- useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
103
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh, replace: jest.fn() }),
64
104
  usePathname: () => "/account/security",
65
105
  redirect: jest.fn(),
66
106
  }));
@@ -77,6 +117,7 @@ const SESSION = {
77
117
  },
78
118
  };
79
119
 
120
+ const BASE_URL = "https://site.example.com";
80
121
  const ENV = process.env;
81
122
  const ENV_KEYS = [
82
123
  "GOOGLE_CLIENT_ID",
@@ -86,6 +127,9 @@ const ENV_KEYS = [
86
127
  "AUTH_EMAIL_SERVER",
87
128
  "AUTH_EMAIL_FROM",
88
129
  "AUTH_EMAIL_WEBHOOK_SECRET",
130
+ "AILK_AUTH_PASSWORD_RESET",
131
+ "BETTER_AUTH_URL",
132
+ "BETTER_AUTH_SECRET",
89
133
  ] as const;
90
134
 
91
135
  let contentRoot: string;
@@ -97,6 +141,12 @@ beforeEach(() => {
97
141
  process.env.AILK_CONTENT_ROOT = contentRoot;
98
142
  writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
99
143
  mockGetSession.mockReset().mockResolvedValue(SESSION);
144
+ mockListUserAccounts.mockReset().mockResolvedValue([]);
145
+ mockRequestPasswordReset.mockReset().mockResolvedValue({ data: { status: true }, error: null });
146
+ mockSignOut.mockReset().mockResolvedValue({ data: { success: true }, error: null });
147
+ mockPush.mockReset();
148
+ mockRefresh.mockReset();
149
+ mockSeam.stale = false;
100
150
  });
101
151
 
102
152
  afterEach(() => {
@@ -108,6 +158,26 @@ async function renderPage(locale = "en"): Promise<void> {
108
158
  render(await Page({ params: Promise.resolve({ locale }) }));
109
159
  }
110
160
 
161
+ /** The five `resetSendConfigured()` conjuncts (C1). */
162
+ function enableReset(): void {
163
+ process.env.AILK_AUTH_PASSWORD_RESET = "true";
164
+ process.env.AUTH_EMAIL_SERVER = "smtp://x";
165
+ process.env.AUTH_EMAIL_FROM = "site@example.com";
166
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "w".repeat(32);
167
+ process.env.BETTER_AUTH_URL = BASE_URL;
168
+ process.env.BETTER_AUTH_SECRET = "s".repeat(32);
169
+ }
170
+
171
+ function enableGoogle(): void {
172
+ process.env.GOOGLE_CLIENT_ID = "id";
173
+ process.env.GOOGLE_CLIENT_SECRET = "secret";
174
+ }
175
+
176
+ function enableGitHub(): void {
177
+ process.env.GITHUB_CLIENT_ID = "id";
178
+ process.env.GITHUB_CLIENT_SECRET = "secret";
179
+ }
180
+
111
181
  describe("the frame (D7, D8)", () => {
112
182
  it("renders in the settings frame with Security current", async () => {
113
183
  await renderPage();
@@ -118,22 +188,222 @@ describe("the frame (D7, D8)", () => {
118
188
  });
119
189
  });
120
190
 
121
- describe("Sign-in — the configured methods (D7, moved from general.test.tsx)", () => {
122
- it("lists each configured method as Available", async () => {
123
- process.env.GOOGLE_CLIENT_ID = "id";
124
- process.env.GOOGLE_CLIENT_SECRET = "secret";
125
- process.env.AUTH_EMAIL_SERVER = "smtp://x";
126
- process.env.AUTH_EMAIL_FROM = "site@example.com";
191
+ describe("the standalone shape (#8430)", () => {
192
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
193
+ await renderPage();
194
+ const settings = screen.getByTestId("account-settings");
195
+ expect(settings).toHaveClass("min-h-screen");
196
+ const column = within(settings).getByRole("complementary");
197
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
198
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
199
+ expect(within(settings).getByRole("main")).toContainElement(
200
+ screen.getByRole("heading", { level: 1 }),
201
+ );
202
+ });
203
+ });
204
+
205
+ describe("AC-2 — a site or hub that can email a password link", () => {
206
+ beforeEach(enableReset);
207
+
208
+ it("with no password: Not set, the create note and Set password — read from the session's own accounts", async () => {
209
+ await renderPage();
210
+ expect(screen.getByRole("heading", { level: 2 })).toHaveTextContent("Sign-in methods");
211
+ expect(screen.getByTestId("account-security-password-status")).toHaveTextContent("Not set");
212
+ expect(screen.getByTestId("account-security-password-note")).toHaveTextContent(
213
+ "We'll email ada@example.com a secure link to create your password.",
214
+ );
215
+ expect(screen.getByTestId("account-security-password-request")).toHaveTextContent("Set password");
216
+ expect(mockListUserAccounts).toHaveBeenCalledTimes(1);
217
+ expect(mockListUserAccounts.mock.calls[0]?.[0]).toEqual({ headers: expect.any(Headers) });
218
+ expect(document.querySelector('input[type="password"]')).toBeNull();
219
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
220
+ });
221
+
222
+ it("with a credential account: Set, the change note and Change password", async () => {
223
+ mockListUserAccounts.mockResolvedValue([{ providerId: "google" }, { providerId: "credential" }]);
224
+ await renderPage();
225
+ expect(screen.getByTestId("account-security-password-status")).toHaveTextContent("Set");
226
+ expect(screen.getByTestId("account-security-password-note")).toHaveTextContent(
227
+ "We'll email ada@example.com a secure link to change your password.",
228
+ );
229
+ expect(screen.getByTestId("account-security-password-request")).toHaveTextContent("Change password");
230
+ });
231
+
232
+ it("a failed accounts read renders the error row in the block's place, with Log out still rendered", async () => {
233
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
234
+ mockListUserAccounts.mockRejectedValue(new TypeError("db down"));
235
+ await renderPage();
236
+ expect(within(screen.getByTestId("account-signin-method")).getByTestId("inbox-error")).toHaveTextContent(
237
+ "We couldn't check your password settings.",
238
+ );
239
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
240
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
241
+ expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([
242
+ "[account] read failed source=security-password errName=TypeError",
243
+ ]);
244
+ errorSpy.mockRestore();
245
+ });
246
+
247
+ it.each([
248
+ ["en", `${BASE_URL}/reset-password`],
249
+ ["fr", `${BASE_URL}/fr/reset-password`],
250
+ ])("Set password (%s) requests a link for the session's own address with the server-built redirectTo", async (locale, redirectTo) => {
251
+ await renderPage(locale);
252
+ await act(async () => {
253
+ fireEvent.click(screen.getByTestId("account-security-password-request"));
254
+ });
255
+ expect(mockRequestPasswordReset).toHaveBeenCalledTimes(1);
256
+ expect(mockRequestPasswordReset).toHaveBeenCalledWith({ email: "ada@example.com", redirectTo });
257
+ expect(screen.getByTestId("account-security-password-sent")).toBeInTheDocument();
258
+ });
259
+
260
+ it("a refused request shows the error, not the sent line", async () => {
261
+ mockRequestPasswordReset.mockResolvedValue({ data: null, error: { status: 429 } });
262
+ await renderPage();
263
+ await act(async () => {
264
+ fireEvent.click(screen.getByTestId("account-security-password-request"));
265
+ });
266
+ expect(screen.getByTestId("account-security-password-error")).toBeInTheDocument();
267
+ expect(screen.queryByTestId("account-security-password-sent")).not.toBeInTheDocument();
268
+ });
269
+
270
+ it("with Google configured as well, no provider list renders", async () => {
271
+ enableGoogle();
272
+ await renderPage();
273
+ expect(screen.getByTestId("account-security-password")).toBeInTheDocument();
274
+ expect(screen.queryByText(/Google/)).not.toBeInTheDocument();
275
+ });
276
+
277
+ it("a seam rendered before #8431 (no accounts reader) degrades to the sentence, never the error row (D2b)", async () => {
278
+ mockSeam.stale = true;
279
+ await renderPage();
280
+ // The five reset conjuncts include the email link's four, so a site that
281
+ // can email a password link always offers the email link too.
282
+ expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(
283
+ "You sign in with a link sent to ada@example.com or your email and password.",
284
+ );
285
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
286
+ expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
287
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
288
+ expect(mockListUserAccounts).not.toHaveBeenCalled();
289
+ });
290
+ });
291
+
292
+ describe("AC-3 — a site that cannot email a password link shows one sentence", () => {
293
+ it.each([
294
+ ["Google alone", [enableGoogle], "You sign in with Google."],
295
+ ["Google and GitHub", [enableGoogle, enableGitHub], "You sign in with Google or GitHub."],
296
+ [
297
+ "the email link alone",
298
+ [
299
+ () => {
300
+ process.env.AUTH_EMAIL_WEBHOOK_SECRET = "w".repeat(32);
301
+ process.env.AUTH_EMAIL_FROM = "site@example.com";
302
+ process.env.BETTER_AUTH_URL = BASE_URL;
303
+ process.env.BETTER_AUTH_SECRET = "s".repeat(32);
304
+ },
305
+ ],
306
+ "You sign in with a link sent to ada@example.com.",
307
+ ],
308
+ [
309
+ "the password method without the reset flag",
310
+ [
311
+ () => {
312
+ process.env.AUTH_EMAIL_SERVER = "smtp://x";
313
+ process.env.AUTH_EMAIL_FROM = "site@example.com";
314
+ },
315
+ ],
316
+ "You sign in with your email and password.",
317
+ ],
318
+ ])("%s", async (_label, arrange, sentence) => {
319
+ for (const step of arrange) step();
320
+ await renderPage();
321
+ expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(sentence);
322
+ expect(screen.queryByText("You're signed in as ada@example.com.")).not.toBeInTheDocument();
323
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
324
+ expect(screen.queryByText("Sign-in methods")).not.toBeInTheDocument();
325
+ expect(screen.queryByText("Available")).not.toBeInTheDocument();
326
+ expect(mockListUserAccounts).not.toHaveBeenCalled();
327
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
328
+ });
329
+
330
+ it("with no configured method it names the signed-in address (C5)", async () => {
331
+ await renderPage();
332
+ expect(screen.getByTestId("account-security-sentence")).toHaveTextContent(
333
+ "You're signed in as ada@example.com.",
334
+ );
335
+ });
336
+
337
+ it("the old Sign-in keys are gone and every added key carries its D5 value", () => {
338
+ const msgs = require("../../../../../messages/en.json") as {
339
+ account: { settings: { signIn: Record<string, unknown>; security: Record<string, unknown> } };
340
+ };
341
+ const { signIn, security } = msgs.account.settings;
342
+ expect(Object.keys(signIn)).toEqual(["lead"]);
343
+ expect(security).toEqual({
344
+ title: "Security",
345
+ methodsHeading: "Sign-in methods",
346
+ passwordLabel: "Password",
347
+ passwordSet: "Set",
348
+ passwordNotSet: "Not set",
349
+ setHint: "Add a password so you can also sign in with your email and password.",
350
+ changeHint: "Change the password you sign in with.",
351
+ setNote: "We'll email {email} a secure link to create your password.",
352
+ changeNote: "We'll email {email} a secure link to change your password.",
353
+ setButton: "Set password",
354
+ changeButton: "Change password",
355
+ sending: "Sending…",
356
+ sent: "Link sent. It works once, for an hour. Saving a password signs you out everywhere.",
357
+ sendFailed: "We couldn't send the email. Try again in a few minutes.",
358
+ logOut: "Log out",
359
+ logOutFailed: "Log out didn't finish. Try again.",
360
+ signsInWith: "You sign in with {methods}.",
361
+ signedInAs: "You're signed in as {email}.",
362
+ methods: {
363
+ google: "Google",
364
+ github: "GitHub",
365
+ magicLink: "a link sent to {email}",
366
+ password: "your email and password",
367
+ },
368
+ managedAt: "Your sign-in is managed at {hub}.",
369
+ managedUnnamed: "Your sign-in is managed by our main site.",
370
+ manageOnHub: "Manage sign-in on {hub}",
371
+ passwordUnavailable: "We couldn't check your password settings.",
372
+ });
373
+ });
374
+ });
375
+
376
+ describe("AC-4 — Log out on a site", () => {
377
+ it.each([
378
+ ["en", "/sign-in"],
379
+ ["fr", "/fr/sign-in"],
380
+ ])("(%s) signs out through Better Auth once and lands on the sign-in page", async (locale, href) => {
381
+ enableGoogle();
382
+ await renderPage(locale);
383
+ await act(async () => {
384
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
385
+ });
386
+ expect(mockSignOut).toHaveBeenCalledTimes(1);
387
+ expect(mockPush).toHaveBeenCalledWith(href);
388
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
389
+ });
390
+
391
+ it("a refused sign-out navigates nowhere and shows the error", async () => {
392
+ mockSignOut.mockResolvedValue({ data: null, error: { status: 500 } });
127
393
  await renderPage();
128
- const signIn = screen.getByTestId("account-signin-method");
129
- expect(within(signIn).getByTestId("account-signin-google")).toHaveTextContent("Available");
130
- expect(within(signIn).getByText("Google")).toBeInTheDocument();
131
- expect(within(signIn).getByText("Password")).toBeInTheDocument();
132
- expect(within(signIn).queryByText("GitHub")).not.toBeInTheDocument();
394
+ await act(async () => {
395
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
396
+ });
397
+ expect(mockPush).not.toHaveBeenCalled();
398
+ expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
133
399
  });
134
400
 
135
- it("no configured method says so", async () => {
401
+ it.each([
402
+ ["password", enableReset],
403
+ ["sentence", enableGoogle],
404
+ ])("the %s variant has no 'other devices' control", async (_label, arrange) => {
405
+ arrange();
136
406
  await renderPage();
137
- expect(screen.getByTestId("account-signin-method")).toHaveTextContent("No sign-in method is configured.");
407
+ expect(screen.queryByText(/other devices/i)).toBeNull();
138
408
  });
139
409
  });
@@ -9,6 +9,11 @@
9
9
  * for D2–D9. A `#billing` fragment never reaches the server, so the server
10
10
  * redirect is to General and `BillingAnchor` forwards the fragment there
11
11
  * (`general.test.tsx` § AC-6).
12
+ *
13
+ * #8441 — `SETTINGS_ROUTES` (settings-frame.tsx) is the one list of User
14
+ * settings' routes: the tabs read it, and so does `isSettingsPath`, the
15
+ * exact, locale-aware membership test the account frame takes the settings
16
+ * routes out of its shell with (layout.test.tsx renders each route).
12
17
  */
13
18
 
14
19
  const mockRedirect = jest.fn();
@@ -18,6 +23,7 @@ jest.mock("next/navigation", () => ({
18
23
  }));
19
24
 
20
25
  import Page from "../settings/page";
26
+ import { isSettingsPath, memberSettingsFrame, SETTINGS_ROUTES, type Translate } from "../settings-frame";
21
27
 
22
28
  beforeEach(() => {
23
29
  mockRedirect.mockReset();
@@ -35,3 +41,52 @@ describe("AC-6 — /account/settings redirects to /account/general (D8)", () =>
35
41
  expect(mockRedirect).toHaveBeenCalledWith("/fr/account/general");
36
42
  });
37
43
  });
44
+
45
+ describe("the one list of User settings' routes (#8441)", () => {
46
+ it("names the four tabs, in the reference's order", () => {
47
+ expect(SETTINGS_ROUTES.map(({ key, path }) => [key, path])).toEqual([
48
+ ["general", "/account/general"],
49
+ ["security", "/account/security"],
50
+ ["notifications", "/account/notifications"],
51
+ ["billing", "/account/billing"],
52
+ ]);
53
+ });
54
+
55
+ it.each(["en", "fr"])("the settings frame's tabs are that list, in %s", (locale) => {
56
+ const t: Translate = (key) => key;
57
+ const { tabs } = memberSettingsFrame(locale, t, "security");
58
+ expect(tabs.map(({ key, href, label }) => [key, href, label])).toEqual(
59
+ SETTINGS_ROUTES.map(({ key, path, titleKey }) => [
60
+ key,
61
+ locale === "en" ? path : `/${locale}${path}`,
62
+ titleKey,
63
+ ]),
64
+ );
65
+ });
66
+ });
67
+
68
+ describe("isSettingsPath — an exact, locale-aware match (#8441)", () => {
69
+ it.each(SETTINGS_ROUTES.map(({ path }) => path))("%s is a settings route, bare or prefixed", (path) => {
70
+ expect(isSettingsPath(path, "en")).toBe(true);
71
+ expect(isSettingsPath(`/fr${path}`, "fr")).toBe(true);
72
+ });
73
+
74
+ it.each([
75
+ ["/account", "en"],
76
+ ["/account/settings", "en"],
77
+ ["/account/support", "en"],
78
+ ["/account/artifacts", "en"],
79
+ // A sub-route stays inside the shell unless it is listed.
80
+ ["/account/billing/history", "en"],
81
+ ["/fr/account/billing/history", "fr"],
82
+ ["/account/general/", "en"],
83
+ ["/account/generalx", "en"],
84
+ // Only the request's own locale is a prefix.
85
+ ["/fr/account/general", "en"],
86
+ ["/de/account/general", "fr"],
87
+ ["/frx/account/general", "fr"],
88
+ ["", "en"],
89
+ ])("%s (locale %s) is not", (path, locale) => {
90
+ expect(isSettingsPath(path, locale)).toBe(false);
91
+ });
92
+ });
@@ -1,4 +1,4 @@
1
- "use client"; // the shell owns collapse state; sign-out is a client-side Better Auth call + router navigation
1
+ "use client"; // the shell owns collapse state; sign-out is a client-side hub-session POST or Better Auth call + router navigation
2
2
 
3
3
  /**
4
4
  * AccountFrame — the account area's SignedInShell composition (#7430, spec
@@ -7,8 +7,21 @@
7
7
  * what only the client has: the current path and the sign-out call.
8
8
  *
9
9
  * `createAuthClient()` at module scope is the `sign-in-gate.tsx` idiom. Log
10
- * out ends the session, then navigates home and refreshes so every RSC under
11
- * the `(authed)` gate re-reads the (now absent) session.
10
+ * out ends the session, then navigates and refreshes so every RSC under the
11
+ * `(authed)` gate re-reads the (now absent) session.
12
+ *
13
+ * #8465 — a spoke that signs in through its hub (#7894) holds its session in
14
+ * a sealed cookie only `POST /api/hub-session/sign-out` clears (it also
15
+ * revokes the credential at the hub); Better Auth's `signOut()` hits a route
16
+ * that spoke does not serve. The server layout reads the generated seam's
17
+ * `SIGN_OUT_ENDPOINT` and hands it down as `signOutEndpoint` — the signed-in
18
+ * Nav's own pattern, because a client component cannot import the seam. With
19
+ * an endpoint, Log out POSTs it and navigates to the sign-in page only when
20
+ * the answer was 2xx; a refused or failed sign-out (a 403 on an Origin
21
+ * mismatch, a 5xx, a network error) leaves the cookie valid, so the frame
22
+ * stays on the page and says so, never routing away with a live session.
23
+ * Without an endpoint (a hub or a single site) nothing changes: Better Auth,
24
+ * then home.
12
25
  *
13
26
  * #8231 D1, as amended by #8334 — the rail reads Inbox, a Library group
14
27
  * holding Artifacts, and the footer's ?. Settings is not a rail row: it lives
@@ -35,6 +48,15 @@
35
48
  * portals to `document.body` (the avatar/help menus, For-you, New, the phone
36
49
  * sheets) without the wrapper adding a box — `contents` keeps the shell's own
37
50
  * layout (`min-h-screen`) unchanged.
51
+ *
52
+ * #8430 — the User settings routes take the screen as the Admin's do: the
53
+ * frame renders the page alone, inside the marker only — no rail, no Inbox,
54
+ * no AppShell — and the page draws the shared `AccountSettingsFrame` in its
55
+ * standalone shape, its own column flush and full height with Back to the
56
+ * Inbox at its top. The marker stays, so the Admin's tokens still reach the
57
+ * settings in both modes. Which routes those are is `isSettingsPath` — an
58
+ * exact match on `SETTINGS_ROUTES`, the list the settings tabs read too
59
+ * (#8441), so a sub-route stays inside the shell.
38
60
  */
39
61
 
40
62
  import {
@@ -50,7 +72,7 @@ import {
50
72
  type SignedInShellProps,
51
73
  } from "@working-theory/templates/shells";
52
74
  import { localeHref } from "@working-theory/ui";
53
- import { Link } from "@working-theory/ui/primitives";
75
+ import { InlineMessage, Link } from "@working-theory/ui/primitives";
54
76
  import { createAuthClient } from "better-auth/react";
55
77
  import { Inbox, Layers, Settings, Sparkles } from "lucide-react";
56
78
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
@@ -58,6 +80,7 @@ import { useState, type ReactElement, type ReactNode } from "react";
58
80
 
59
81
  import { ACCOUNT_PERSIST_KEY } from "./for-you-labels";
60
82
  import { NewMenuSlotProvider } from "./new-menu";
83
+ import { isSettingsPath } from "./settings-frame";
61
84
 
62
85
  const authClient = createAuthClient();
63
86
 
@@ -74,6 +97,8 @@ export interface AccountFrameLabels extends SignedInShellLabels {
74
97
  /** The rail's Library group heading (#8231 D1). */
75
98
  library: string;
76
99
  artifacts: string;
100
+ /** Shown when a hub sign-out is refused or fails (#8465). */
101
+ signOutFailed: string;
77
102
  }
78
103
 
79
104
  export interface AccountFrameProps {
@@ -84,6 +109,14 @@ export interface AccountFrameProps {
84
109
  helpItems: NonNullable<SignedInShellProps["helpItems"]>;
85
110
  forYou: { items: readonly ForYouItem[]; now: string; labels: ForYouLabels };
86
111
  labels: AccountFrameLabels;
112
+ /**
113
+ * Where Log out POSTs on a spoke that signs in through its hub (the seam's
114
+ * `SIGN_OUT_ENDPOINT`, read by the server layout). Absent or `null`: Better
115
+ * Auth's client signs out (#8465).
116
+ */
117
+ signOutEndpoint?: string | null;
118
+ /** Where Log out lands after a hub sign-out; defaults to the site home. */
119
+ signInHref?: string;
87
120
  children: ReactNode;
88
121
  }
89
122
 
@@ -94,6 +127,8 @@ export function AccountFrame({
94
127
  helpItems,
95
128
  forYou,
96
129
  labels,
130
+ signOutEndpoint,
131
+ signInHref,
97
132
  children,
98
133
  }: AccountFrameProps): ReactElement {
99
134
  const router = useRouter();
@@ -101,12 +136,23 @@ export function AccountFrame({
101
136
  const searchParams = useSearchParams();
102
137
  const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
103
138
  const [topBarActions, setTopBarActions] = useState<HTMLElement | null>(null);
139
+ const [signOutFailed, setSignOutFailed] = useState(false);
104
140
 
105
141
  const homeHref = localeHref(locale, "/");
106
142
  const inboxHref = localeHref(locale, "/account");
107
143
  const artifactsHref = localeHref(locale, "/account/artifacts");
108
144
  const settingsHref = localeHref(locale, "/account/general");
109
145
  const within = (href: string): boolean => pathname === href || pathname.startsWith(`${href}/`);
146
+
147
+ // #8430 — a Settings page draws its own standalone frame: the marker only.
148
+ if (isSettingsPath(pathname, locale)) {
149
+ return (
150
+ <div className="contents" data-ailk-shell="account" data-testid="account-shell">
151
+ {children}
152
+ </div>
153
+ );
154
+ }
155
+
110
156
  // The Inbox row stays selected on an item's own page (/account/inbox/…).
111
157
  const onInbox = pathname === inboxHref || pathname.startsWith(`${inboxHref}/inbox/`);
112
158
  const activePath = onInbox ? inboxHref : within(artifactsHref) ? artifactsHref : undefined;
@@ -118,6 +164,24 @@ export function AccountFrame({
118
164
 
119
165
  const onSignOut = (): void => {
120
166
  void (async () => {
167
+ setSignOutFailed(false);
168
+ if (signOutEndpoint) {
169
+ // A refused or failed sign-out leaves the session cookie valid, so the
170
+ // page must not pretend otherwise by navigating away (#8465).
171
+ try {
172
+ const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
173
+ if (!res.ok) {
174
+ setSignOutFailed(true);
175
+ return;
176
+ }
177
+ } catch {
178
+ setSignOutFailed(true);
179
+ return;
180
+ }
181
+ router.push(signInHref ?? homeHref);
182
+ router.refresh();
183
+ return;
184
+ }
121
185
  try {
122
186
  await authClient.signOut();
123
187
  } catch {
@@ -136,6 +200,7 @@ export function AccountFrame({
136
200
  settings,
137
201
  library,
138
202
  artifacts,
203
+ signOutFailed: signOutFailedLabel,
139
204
  ...shellLabels
140
205
  } = labels;
141
206
 
@@ -222,6 +287,11 @@ export function AccountFrame({
222
287
  : {})}
223
288
  labels={shellLabels}
224
289
  >
290
+ {signOutFailed ? (
291
+ <InlineMessage variant="error" className="px-4 pt-3" data-testid="account-sign-out-error">
292
+ {signOutFailedLabel}
293
+ </InlineMessage>
294
+ ) : null}
225
295
  <PhoneTabProvider tab={phoneTab}>
226
296
  <NewMenuSlotProvider node={onInboxRoute ? topBarActions : null}>
227
297
  {children}
@@ -8,7 +8,8 @@
8
8
  * page's structure: a second column with an "All artifacts" count and a
9
9
  * Review facet group (Ready for review · Changes requested · Approved),
10
10
  * each counted over every artifact with zeros kept, and a table of Title ·
11
- * Latest · Delivered · Review in the read's order — newest delivery first.
11
+ * Latest · Delivered · Review in the read's order — newest delivery first;
12
+ * the Review column is wide enough for its longest state label (#8361).
12
13
  * Below `md` the column becomes a filter select and the table becomes the
13
14
  * phone row list.
14
15
  *
@@ -156,12 +157,8 @@ export default async function AccountArtifactsPage({
156
157
  width: "1/6",
157
158
  minWidth: "28",
158
159
  },
159
- {
160
- key: "review",
161
- header: t("artifacts.columns.review"),
162
- width: "1/6",
163
- minWidth: "32",
164
- },
160
+ // Sized by the frame from the longest state label (#8361).
161
+ { key: "review", header: t("artifacts.columns.review"), fit: "facets" },
165
162
  ]}
166
163
  rows={artifacts.map(row)}
167
164
  labels={{
@@ -2,8 +2,8 @@
2
2
  * /[locale]/account/billing — User settings › Billing (#8339 D8; builds on
3
3
  * #8232 D9, AC-MAA-19).
4
4
  *
5
- * RSC, inside the account layout's SignedInShell with the rail visible, in
6
- * the member's four-tab settings frame (D7). Lays out like the Admin's
5
+ * RSC, outside the account layout's SignedInShell (#8430), in the member's
6
+ * four-tab settings frame (D7) — standalone, the Admin's shape. Lays out like the Admin's
7
7
  * organization Billing page (`OrgBillingPanel`'s `current-plan` layout):
8
8
  * the shared `CurrentPlanCard` (@working-theory/templates/account, spec
9
9
  * 8339 D5), by `loadBilling()`'s answer (`billing-source.ts`, the billing
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * - a subscription — the plan (or `unnamedPlan`), the status plus when
13
13
  * access ends (when set, C4 — `subscription.me` carries no price), the
14
- * card on file, then Manage in Stripe when a portal is available;
14
+ * card on file, then Manage billing when a portal is available;
15
15
  * - no subscription — the same card shape with the plain line and no
16
16
  * button (C5);
17
17
  * - a failed read — the area's error row with Retry, never a false "you