create-ailk 0.17.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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/env-catalog.js +5 -9
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +15 -2
- package/dist/topology.js +83 -4
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +58 -7
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/index.test.ts +86 -47
- package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
- package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/server.test.ts +63 -6
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/app.ts +32 -0
- package/templates/apps/mcp/src/boot.ts +167 -0
- package/templates/apps/mcp/src/http-transport.ts +132 -88
- package/templates/apps/mcp/src/index.ts +19 -126
- package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
- package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
- package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/server.ts +10 -2
- package/templates/apps/mcp/src/serverless-app.ts +88 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
- package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
- package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
- package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
- package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
- package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
- package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
- package/templates/apps/mcp/src/tools/read_content.ts +7 -7
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/search_content.ts +6 -5
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/mcp/tsconfig.json +2 -0
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/site-brand.tsx +22 -6
- package/templates/apps/web/lib/topology.generated.ts +27 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- 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 (#
|
|
3
|
-
*
|
|
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
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
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 {
|
|
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
|
|
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
|
-
|
|
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
|
+
}
|