create-ailk 0.18.0 → 0.19.0

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 (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,116 @@
1
+ "use server";
2
+
3
+ /**
4
+ * account/profile-actions.ts — User settings › General's two member writes
5
+ * (#8232, spec docs/specs/8232-member-settings-account-general-and-spec.md
6
+ * D6; the writes are #8170's).
7
+ *
8
+ * - `saveProfile` — the member's name and photo, `member.updateProfile`
9
+ * (`PATCH /v1/member/profile`). On a spoke the hub-session cookie is then
10
+ * re-sealed from the hub's `/me` (`resealHubSession`, D11), so the new
11
+ * name shows on the next render.
12
+ * - `requestAccountDeletion` — `member.requestDeletion`
13
+ * (`POST /v1/member/deletion-request`). The API files it with the
14
+ * deployment that holds the account (8170 D9) and answers which operator
15
+ * it went to, so nothing here tests the topology.
16
+ *
17
+ * Both send `forwardedCookieInit()`'s init: on a single site the session
18
+ * cookie plus the request's `Origin`, which `apps/api`'s cookie CSRF check
19
+ * needs; on a hub-mode spoke the hub credential. Neither takes an identity
20
+ * argument — who comes from the forwarded credential alone (8170 D1).
21
+ *
22
+ * Not a `*.actions.ts` file and no `*Action`-suffixed export: these writes
23
+ * have no MCP twin by decision (8170's header), and apps/mcp's parity test
24
+ * reads that suffix. `resealHubSession` is imported by its own path, never
25
+ * through `lib/hub-session/index.ts`: `sync-routes` never overwrites a
26
+ * scaffold's existing `index.ts`, so an import through it would not compile
27
+ * there (D13). Logs carry a status or `err.name` only — never a name, a
28
+ * link, an email or a credential.
29
+ */
30
+
31
+ import { member } from "@working-theory/api-client";
32
+
33
+ import { resealHubSession } from "../../../../lib/hub-session/reseal";
34
+ import { forwardedCookieInit } from "../../../../lib/server-api";
35
+
36
+ /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
37
+ function errorName(err: unknown): string {
38
+ return err instanceof Error ? err.name : "unknown";
39
+ }
40
+
41
+ /** The `error.code` of a member write's refusal body, when it has one. */
42
+ function errorCode(body: unknown): string | null {
43
+ if (typeof body !== "object" || body === null) return null;
44
+ const error = (body as { error?: unknown }).error;
45
+ if (typeof error !== "object" || error === null) return null;
46
+ const code = (error as { code?: unknown }).code;
47
+ return typeof code === "string" ? code : null;
48
+ }
49
+
50
+ export type SaveProfileResult =
51
+ | { ok: true; name: string | null; image: string | null }
52
+ | { ok: false; reason: "required" | "photo" | "unverified" | "unavailable" | "failed" };
53
+
54
+ /**
55
+ * Saves the member's name, and their photo when `image` is present:
56
+ * `null` (or a blank link) clears it, and an absent key leaves it as it is.
57
+ */
58
+ export async function saveProfile(input: {
59
+ name: string;
60
+ image?: string | null;
61
+ }): Promise<SaveProfileResult> {
62
+ // Action input is untrusted: a hand-built call may send anything.
63
+ const { name: rawName, image } = (typeof input === "object" && input !== null ? input : {}) as {
64
+ name?: unknown;
65
+ image?: unknown;
66
+ };
67
+ const name = typeof rawName === "string" ? rawName.trim() : "";
68
+ if (!name) return { ok: false, reason: "required" };
69
+ // Anything but a string or null is refused, never read as "clear the photo".
70
+ if (image !== undefined && image !== null && typeof image !== "string") {
71
+ return { ok: false, reason: "photo" };
72
+ }
73
+ const body = image === undefined ? { name } : { name, image: image?.trim() || null };
74
+
75
+ try {
76
+ const res = await member.updateProfile(body, await forwardedCookieInit());
77
+ if (res.ok) {
78
+ if ((await resealHubSession()) === "failed") {
79
+ console.error("[profile-actions] saveProfile reseal=failed");
80
+ }
81
+ return { ok: true, name: res.body.name, image: res.body.image };
82
+ }
83
+ console.error(`[profile-actions] saveProfile status=${res.status}`);
84
+ if (res.status === 400 && errorCode(res.body) === "link_not_https") {
85
+ return { ok: false, reason: "photo" };
86
+ }
87
+ if (res.status === 403) return { ok: false, reason: "unverified" };
88
+ if (res.status === 404 || res.status === 503) return { ok: false, reason: "unavailable" };
89
+ return { ok: false, reason: "failed" };
90
+ } catch (err) {
91
+ console.error(`[profile-actions] saveProfile errName=${errorName(err)}`);
92
+ return { ok: false, reason: "failed" };
93
+ }
94
+ }
95
+
96
+ export type AccountDeletionResult =
97
+ | { ok: true; sentTo: "site-operator" | "hub-operator" }
98
+ | { ok: false; reason: "unverified" | "unavailable" | "failed" };
99
+
100
+ /**
101
+ * Asks the operator who holds the account to delete it. The body is empty,
102
+ * so the API's own subject and message go to the operator.
103
+ */
104
+ export async function requestAccountDeletion(): Promise<AccountDeletionResult> {
105
+ try {
106
+ const res = await member.requestDeletion({}, await forwardedCookieInit());
107
+ if (res.ok) return { ok: true, sentTo: res.body.sentTo };
108
+ console.error(`[profile-actions] requestAccountDeletion status=${res.status}`);
109
+ if (res.status === 403) return { ok: false, reason: "unverified" };
110
+ if (res.status === 404 || res.status === 503) return { ok: false, reason: "unavailable" };
111
+ return { ok: false, reason: "failed" };
112
+ } catch (err) {
113
+ console.error(`[profile-actions] requestAccountDeletion errName=${errorName(err)}`);
114
+ return { ok: false, reason: "failed" };
115
+ }
116
+ }
@@ -0,0 +1,80 @@
1
+ /**
2
+ * /[locale]/account/security — User settings › Security (#8339 D8, NEW).
3
+ *
4
+ * RSC, inside the account layout's SignedInShell with the rail visible, in
5
+ * the member's four-tab settings frame (D7). Holds the former Sign-in
6
+ * section's content, moved here verbatim from General (#8339 C6): how this
7
+ * member signs in (`signInMethod()`, #8232 D7) — the hub line (named or
8
+ * unnamed), the configured methods, or none.
9
+ */
10
+
11
+ import { AccountSettingsFrame, AccountSettingsPane } from "@working-theory/templates/account";
12
+ import { DescriptionList } from "@working-theory/ui/blocks";
13
+ import { Text } from "@working-theory/ui/primitives";
14
+ import type { Metadata } from "next";
15
+ import { getTranslations } from "next-intl/server";
16
+ import type { ReactElement } from "react";
17
+
18
+ import { accountSession } from "../account-data";
19
+ import { memberSettingsFrame, type Translate } from "../settings-frame";
20
+ import { signInMethod, type SignInMethodId } from "../sign-in-method";
21
+
22
+ interface Props {
23
+ params: Promise<{ locale: string }>;
24
+ }
25
+
26
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
27
+ const { locale } = await params;
28
+ const t = await getTranslations({ locale, namespace: "account" });
29
+ return { title: t("settings.security.title"), description: t("settings.signIn.lead") };
30
+ }
31
+
32
+ /** Each sign-in method's label key. */
33
+ const METHOD_KEYS = {
34
+ google: "settings.signIn.methods.google",
35
+ github: "settings.signIn.methods.github",
36
+ "magic-link": "settings.signIn.methods.magicLink",
37
+ email: "settings.signIn.methods.password",
38
+ } as const satisfies Record<SignInMethodId, string>;
39
+
40
+ export default async function AccountSecurityPage({ params }: Props): Promise<ReactElement> {
41
+ const { locale } = await params;
42
+ const t = await getTranslations({ locale, namespace: "account" });
43
+ const session = await accountSession(locale);
44
+ const frame = memberSettingsFrame(locale, t as unknown as Translate, "security");
45
+ const name = session?.ctx.user.name || session?.ctx.user.email || "";
46
+
47
+ const signIn = signInMethod();
48
+
49
+ return (
50
+ <AccountSettingsFrame {...frame} name={name}>
51
+ <AccountSettingsPane
52
+ id="account-settings-security"
53
+ title={t("settings.security.title")}
54
+ lead={t("settings.signIn.lead")}
55
+ >
56
+ <div data-testid="account-signin-method">
57
+ {signIn.kind === "hub" ? (
58
+ <Text as="p" variant="body-sm" tone="muted">
59
+ {signIn.hubHost !== null
60
+ ? t("settings.signIn.viaHub", { hub: signIn.hubHost })
61
+ : t("settings.signIn.viaHubUnnamed")}
62
+ </Text>
63
+ ) : signIn.methods.length > 0 ? (
64
+ <DescriptionList
65
+ items={signIn.methods.map((id) => ({
66
+ label: t(METHOD_KEYS[id]),
67
+ value: t("settings.signIn.available"),
68
+ "data-testid": `account-signin-${id}`,
69
+ }))}
70
+ />
71
+ ) : (
72
+ <Text as="p" variant="body-sm" tone="muted">
73
+ {t("settings.signIn.none")}
74
+ </Text>
75
+ )}
76
+ </div>
77
+ </AccountSettingsPane>
78
+ </AccountSettingsFrame>
79
+ );
80
+ }
@@ -1,367 +1,26 @@
1
1
  /**
2
- * /[locale]/account/settings — User settings (#7430, spec 7423 U4 · D10,
3
- * D10a; DESIGN.md §5.12a's section content).
2
+ * /[locale]/account/settings — redirects to User settings › General (#8232,
3
+ * spec docs/specs/8232-member-settings-account-general-and-spec.md D8).
4
4
  *
5
- * RSC, inside the account layout's SignedInShell with the rail visible
6
- * (D10a — a member's rail has one row, so a separate no-rail frame would add
7
- * a step for nothing). One page of anchored sections — the For-you items
8
- * link to `#general` and `#billing`:
5
+ * Settings is four tabs since #8339 — General · Security · Notifications ·
6
+ * Billing — and this one-page, anchored route (#7430) only forwards. A URL
7
+ * fragment never reaches the server, so `#billing` cannot be redirected
8
+ * here: the browser carries it across this redirect, and General's
9
+ * `BillingAnchor` sends it on to Billing; `#general` lands on General as it
10
+ * is. The hub's authorize step still sends an unverified person here
11
+ * (`VERIFY_EMAIL_PATH`), and this redirect carries them on.
9
12
  *
10
- * - General — color mode (only when the site's theme defines more than
11
- * one mode), the email read-only, and the name the member sets and edits
12
- * (an accepted applicant arrives with none, #7418).
13
- * - Security — the sign-in methods this site has configured; a Password
14
- * row only when the password method is on.
15
- * - Notifications — a note until a preference store exists.
16
- * - Privacy — the cookie settings (`/privacy#cookie-settings`, since the
17
- * account area carries no consent Banner, D4) and "Request account
18
- * deletion", which files a Support request of kind `deletion`.
19
- * - Billing — only when `subscription.me()` answers a status or an
20
- * available portal; absent on `status: null` + no portal, and on any
21
- * 401/403/404 (the commerce-disabled build answers 404).
22
- * - Then any section a module contributes (`accountSettingsSections`).
23
- *
24
- * On a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
25
- * #7894 D10) the account belongs to the hub: the name is a read-only row,
26
- * Security says sign-in happens on the hub, and both the name edit and the
27
- * deletion request are a link to the hub's own account settings — so a
28
- * request about a hub account never lands in this spoke's inbox
29
- * (SEC-7894-11). Billing is absent there because its read answers 404.
30
- *
31
- * No Credentials, Scope, Organizations or Credits section: those are an
32
- * agent's or an operator's (§5.12a), never a member's.
13
+ * The directory keeps the one file General reuses unchanged: `theme-modes.ts`.
33
14
  */
34
15
 
35
- import { subscription, type SubscriptionMeResponse } from "@working-theory/api-client";
36
16
  import { localeHref } from "@working-theory/ui";
37
- import { DescriptionList } from "@working-theory/ui/blocks";
38
- import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
39
- import type { Metadata } from "next";
40
- import { getTranslations } from "next-intl/server";
41
- import type { ReactElement, ReactNode } from "react";
42
-
43
- import { accountSettingsSections } from "../../../../../lib/account-config";
44
- import { getActiveProviderIds } from "../../../../../lib/auth-env";
45
- import { hubSessionConfig } from "../../../../../lib/hub-session/config";
46
- import { SIGN_IN_PROVIDER } from "../../../../../lib/topology.generated";
47
- import { accountSession, logReadFailure } from "../account-data";
48
- import { openBillingPortal } from "../actions";
49
-
50
- import { ColorModeSetting } from "./color-mode";
51
- import { DeletionRequest } from "./deletion-request";
52
- import { NameForm } from "./name-form";
53
- import { siteThemeModeCount } from "./theme-modes";
17
+ import { redirect } from "next/navigation";
54
18
 
55
19
  interface Props {
56
20
  params: Promise<{ locale: string }>;
57
21
  }
58
22
 
59
- export async function generateMetadata({ params }: Props): Promise<Metadata> {
60
- const { locale } = await params;
61
- const t = await getTranslations({ locale, namespace: "account" });
62
- return { title: t("settings.title"), description: t("settings.lead") };
63
- }
64
-
65
- /** The section role DESIGN.md §5.12 names: a title, a muted subtitle, then the fields. */
66
- function Section({
67
- id,
68
- title,
69
- lead,
70
- children,
71
- }: {
72
- id: string;
73
- title: string;
74
- lead?: string;
75
- children: ReactNode;
76
- }): ReactElement {
77
- return (
78
- <section
79
- id={id}
80
- aria-labelledby={`${id}-title`}
81
- className="flex scroll-mt-8 flex-col gap-4 border-t border-(--color-border) pt-8"
82
- data-testid={`account-settings-${id}`}
83
- >
84
- <div>
85
- <Heading level={2} visual={4} id={`${id}-title`}>
86
- {title}
87
- </Heading>
88
- {lead ? (
89
- <Text as="p" variant="body-sm" tone="muted" className="mt-1">
90
- {lead}
91
- </Text>
92
- ) : null}
93
- </div>
94
- {children}
95
- </section>
96
- );
97
- }
98
-
99
- /** The sign-in methods this deployment has configured, as message keys. */
100
- const METHOD_KEYS: Record<string, string> = {
101
- google: "settings.security.google",
102
- github: "settings.security.github",
103
- "magic-link": "settings.security.magicLink",
104
- email: "settings.security.password",
105
- };
106
-
107
- /** The hub's own account settings page, on a spoke that signs in through it. */
108
- function hubSettingsHref(): string | null {
109
- const config = hubSessionConfig();
110
- return config === null ? null : new URL("/account/settings", config.hubOrigin).toString();
111
- }
112
-
113
- /** A link to the hub's account settings, in place of a control that would act on the spoke. */
114
- function HubSettingsLink({
115
- href,
116
- label,
117
- testId,
118
- }: {
119
- href: string;
120
- label: string;
121
- testId: string;
122
- }): ReactElement {
123
- return (
124
- <div>
125
- <Link href={href} className="inline-flex min-h-6 items-center" data-testid={testId}>
126
- {label}
127
- </Link>
128
- </div>
129
- );
130
- }
131
-
132
- /** The statuses with a message key; any other renders as the API sent it. */
133
- const KNOWN_STATUSES: ReadonlySet<string> = new Set(["trialing", "active", "past_due", "canceled"]);
134
-
135
- /**
136
- * Billing's read: `null` on any non-2xx — 401/403/404 by D10 — and a failed
137
- * read leaves the section out rather than failing the whole page. A failure
138
- * other than those three is logged, so it is not silent to the operator.
139
- */
140
- async function readSubscription(
141
- init: { headers: Record<string, string> },
142
- ): Promise<SubscriptionMeResponse | null> {
143
- try {
144
- const res = await subscription.me(init);
145
- if (res.ok) return res.body;
146
- if (![401, 403, 404].includes(res.status)) {
147
- console.error(`[account] read failed source=billing status=${res.status}`);
148
- }
149
- return null;
150
- } catch (err) {
151
- logReadFailure("billing", err);
152
- return null;
153
- }
154
- }
155
-
156
- export default async function AccountSettingsPage({ params }: Props): Promise<ReactElement> {
23
+ export default async function AccountSettingsPage({ params }: Props): Promise<never> {
157
24
  const { locale } = await params;
158
- const t = await getTranslations({ locale, namespace: "account" });
159
- const session = await accountSession(locale);
160
-
161
- // Unreachable behind the (authed) gate; a session that expired between
162
- // the layout and the page renders the heading alone.
163
- if (!session) {
164
- return (
165
- <div className="max-w-3xl py-8" data-testid="account-settings">
166
- <Heading level={1} visual={2}>
167
- {t("settings.title")}
168
- </Heading>
169
- </div>
170
- );
171
- }
172
-
173
- const { ctx } = session;
174
- const sub = await readSubscription(ctx.init);
175
- const showBilling = sub !== null && (sub.status !== null || sub.portalAvailable);
176
- const methods = getActiveProviderIds().filter((id) => id in METHOD_KEYS);
177
- const viaHub = SIGN_IN_PROVIDER === "hub";
178
- const hubHref = viaHub ? hubSettingsHref() : null;
179
-
180
- return (
181
- <div className="flex max-w-3xl flex-col gap-8 py-8" data-testid="account-settings">
182
- <div>
183
- <Heading level={1} visual={2}>
184
- {t("settings.title")}
185
- </Heading>
186
- <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
187
- {t("settings.lead")}
188
- </Text>
189
- </div>
190
-
191
- <Section id="general" title={t("settings.general.title")} lead={t("settings.general.lead")}>
192
- {siteThemeModeCount() > 1 ? (
193
- <div className="flex flex-col items-start gap-2">
194
- <Text as="p" variant="body-sm" className="font-medium">
195
- {t("settings.general.colorMode")}
196
- </Text>
197
- <ColorModeSetting
198
- labels={{
199
- title: t("settings.general.colorMode"),
200
- light: t("settings.general.light"),
201
- auto: t("settings.general.auto"),
202
- dark: t("settings.general.dark"),
203
- }}
204
- />
205
- </div>
206
- ) : null}
207
- <DescriptionList
208
- items={[
209
- {
210
- label: t("settings.general.email"),
211
- value: ctx.user.email,
212
- "data-testid": "account-settings-email",
213
- },
214
- ...(viaHub && ctx.user.name
215
- ? [
216
- {
217
- label: t("settings.general.name"),
218
- value: ctx.user.name,
219
- "data-testid": "account-settings-name",
220
- },
221
- ]
222
- : []),
223
- ]}
224
- />
225
- {viaHub ? (
226
- hubHref !== null ? (
227
- <HubSettingsLink
228
- href={hubHref}
229
- label={t("settings.general.manageOnHub")}
230
- testId="account-settings-manage-on-hub"
231
- />
232
- ) : null
233
- ) : (
234
- <NameForm
235
- initialName={ctx.user.name ?? ""}
236
- labels={{
237
- name: t("settings.general.name"),
238
- placeholder: t("settings.general.namePlaceholder"),
239
- save: t("settings.general.save"),
240
- saving: t("settings.general.saving"),
241
- saved: t("settings.general.saved"),
242
- unsaved: t("settings.general.unsaved"),
243
- clean: t("settings.general.clean"),
244
- required: t("settings.general.nameRequired"),
245
- error: t("settings.general.saveError"),
246
- }}
247
- />
248
- )}
249
- </Section>
250
-
251
- <Section id="security" title={t("settings.security.title")} lead={t("settings.security.lead")}>
252
- {viaHub ? (
253
- <Text as="p" variant="body-sm" tone="muted">
254
- {t("settings.security.viaHub")}
255
- </Text>
256
- ) : methods.length > 0 ? (
257
- <div className="flex flex-col gap-2">
258
- <Text as="p" variant="body-sm" className="font-medium">
259
- {t("settings.security.methods")}
260
- </Text>
261
- <DescriptionList
262
- items={methods.map((id) => ({
263
- label: ctx.t(METHOD_KEYS[id]!),
264
- value: t("settings.security.available"),
265
- "data-testid": `account-signin-${id}`,
266
- }))}
267
- />
268
- </div>
269
- ) : (
270
- <Text as="p" variant="body-sm" tone="muted">
271
- {t("settings.security.none")}
272
- </Text>
273
- )}
274
- </Section>
275
-
276
- <Section
277
- id="notifications"
278
- title={t("settings.notifications.title")}
279
- lead={t("settings.notifications.lead")}
280
- >
281
- <Text as="p" variant="body-sm" tone="muted">
282
- {t("settings.notifications.placeholder")}
283
- </Text>
284
- </Section>
285
-
286
- <Section id="privacy" title={t("settings.privacy.title")} lead={t("settings.privacy.lead")}>
287
- <div>
288
- <Link
289
- href={localeHref(locale, "/privacy#cookie-settings")}
290
- className="inline-flex min-h-6 items-center"
291
- data-testid="account-cookie-settings"
292
- >
293
- {t("settings.privacy.cookies")}
294
- </Link>
295
- </div>
296
- {viaHub ? (
297
- hubHref !== null ? (
298
- <HubSettingsLink
299
- href={hubHref}
300
- label={t("settings.general.manageOnHub")}
301
- testId="account-privacy-manage-on-hub"
302
- />
303
- ) : null
304
- ) : (
305
- <div className="flex flex-col gap-2">
306
- <Text as="p" variant="body-sm" tone="muted">
307
- {t("settings.privacy.deletionLead")}
308
- </Text>
309
- <DeletionRequest
310
- labels={{
311
- action: t("settings.privacy.deletion"),
312
- subject: t("settings.privacy.deletionSubject"),
313
- message: t("settings.privacy.deletionMessage"),
314
- sent: t("settings.privacy.deletionSent"),
315
- error: t("settings.privacy.deletionError"),
316
- }}
317
- />
318
- </div>
319
- )}
320
- </Section>
321
-
322
- {showBilling ? (
323
- <Section id="billing" title={t("settings.billing.title")} lead={t("settings.billing.lead")}>
324
- {sub.status !== null || sub.accessRevokesAt ? (
325
- <DescriptionList
326
- items={[
327
- ...(sub.status !== null
328
- ? [
329
- {
330
- label: t("settings.billing.status"),
331
- value: KNOWN_STATUSES.has(sub.status)
332
- ? ctx.t(`settings.billing.statuses.${sub.status}`)
333
- : sub.status,
334
- },
335
- ]
336
- : []),
337
- ...(sub.accessRevokesAt
338
- ? [
339
- {
340
- label: t("settings.billing.accessRevokesAt"),
341
- value: new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(
342
- new Date(sub.accessRevokesAt),
343
- ),
344
- },
345
- ]
346
- : []),
347
- ]}
348
- />
349
- ) : null}
350
- {sub.portalAvailable ? (
351
- <form action={openBillingPortal}>
352
- <Button type="submit" variant="secondary" data-testid="account-billing-manage">
353
- {t("settings.billing.manage")}
354
- </Button>
355
- </form>
356
- ) : null}
357
- </Section>
358
- ) : null}
359
-
360
- {accountSettingsSections.map((section) => (
361
- <Section key={section.id} id={section.id} title={ctx.t(section.title)}>
362
- {section.render(ctx)}
363
- </Section>
364
- ))}
365
- </div>
366
- );
25
+ redirect(localeHref(locale, "/account/general"));
367
26
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * settings-frame.tsx — User settings' four tabs, General · Security ·
3
+ * Notifications · Billing (#8339 D7; builds on #8232 D1).
4
+ *
5
+ * `memberSettingsFrame` supplies the member's half of the shared
6
+ * `AccountSettingsFrame` (@working-theory/templates/account, spec 8339 D2):
7
+ * `standalone: false` (the member area already renders inside AppShell's
8
+ * own `<main>`), Back to the Inbox, and the four tabs in the reference's
9
+ * order. Every member settings page calls it and renders the result through
10
+ * `AccountSettingsFrame` (General, via the shared `AccountSettings`) or
11
+ * directly (Security, Notifications, Billing).
12
+ *
13
+ * `SettingsSection` stays as it is: contributed sections
14
+ * (`accountSettingsSections`, `accountBillingSections`) still use it, as do
15
+ * General's and Billing's own trailing content.
16
+ */
17
+
18
+ import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
19
+ import { localeHref } from "@working-theory/ui";
20
+ import { Heading, Icon, Text } from "@working-theory/ui/primitives";
21
+ import { Bell, CreditCard, ShieldCheck, UserRound } from "lucide-react";
22
+ import type { ReactElement, ReactNode } from "react";
23
+
24
+ /** The `next-intl` translator General/Security/Notifications/Billing each
25
+ * hold. Widened from `getTranslations`'s own literal-key-checked type (the
26
+ * `AccountTranslate` idiom, `account-data.ts`) — a settings page's own
27
+ * string keys are static, but the function is shared across four route
28
+ * files with four different generic instantiations. Call sites cast their
29
+ * own `t` with `as unknown as Translate`. */
30
+ export type Translate = (key: string, values?: Record<string, unknown>) => string;
31
+
32
+ /** The member's half of `AccountSettingsFrameProps` — every settings page's
33
+ * `frame` prop, less `name` (the caller's own session) and `children`. */
34
+ export function memberSettingsFrame(
35
+ locale: string,
36
+ t: Translate,
37
+ active: "general" | "security" | "notifications" | "billing",
38
+ ): Omit<AccountSettingsFrameProps, "name" | "children"> {
39
+ const tabs: readonly AccountSettingsTab[] = [
40
+ {
41
+ key: "general",
42
+ label: t("settings.general.title"),
43
+ href: localeHref(locale, "/account/general"),
44
+ icon: <Icon icon={UserRound} size="sm" decorative className="shrink-0" />,
45
+ },
46
+ {
47
+ key: "security",
48
+ label: t("settings.security.title"),
49
+ href: localeHref(locale, "/account/security"),
50
+ icon: <Icon icon={ShieldCheck} size="sm" decorative className="shrink-0" />,
51
+ },
52
+ {
53
+ key: "notifications",
54
+ label: t("settings.notifications.title"),
55
+ href: localeHref(locale, "/account/notifications"),
56
+ icon: <Icon icon={Bell} size="sm" decorative className="shrink-0" />,
57
+ },
58
+ {
59
+ key: "billing",
60
+ label: t("settings.billing.title"),
61
+ href: localeHref(locale, "/account/billing"),
62
+ icon: <Icon icon={CreditCard} size="sm" decorative className="shrink-0" />,
63
+ },
64
+ ];
65
+
66
+ return {
67
+ standalone: false,
68
+ back: { label: t("settings.back"), href: localeHref(locale, "/account") },
69
+ railLabel: t("settings.title"),
70
+ accountLabel: t("settings.account"),
71
+ tabs,
72
+ active,
73
+ };
74
+ }
75
+
76
+ /** The section role DESIGN.md §5.12 names: a title, a muted subtitle, then the fields. */
77
+ export function SettingsSection({
78
+ id,
79
+ title,
80
+ lead,
81
+ children,
82
+ }: {
83
+ id: string;
84
+ title: string;
85
+ lead?: string;
86
+ children: ReactNode;
87
+ }): ReactElement {
88
+ return (
89
+ <section
90
+ id={id}
91
+ aria-labelledby={`${id}-title`}
92
+ className="flex scroll-mt-8 flex-col gap-4 border-t border-(--color-border) pt-8"
93
+ data-testid={`account-settings-${id}`}
94
+ >
95
+ <div>
96
+ <Heading level={2} visual={4} id={`${id}-title`}>
97
+ {title}
98
+ </Heading>
99
+ {lead ? (
100
+ <Text as="p" variant="body-sm" tone="muted" className="mt-1">
101
+ {lead}
102
+ </Text>
103
+ ) : null}
104
+ </div>
105
+ {children}
106
+ </section>
107
+ );
108
+ }