create-ailk 0.19.1 → 0.20.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/README.md +64 -7
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/lib/fetch-template-snapshot.d.ts +21 -0
- package/dist/lib/fetch-template-snapshot.js +205 -0
- package/dist/lib/merge-three-way.d.ts +31 -0
- package/dist/lib/merge-three-way.js +65 -0
- package/dist/module-architecture.js +55 -2
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +32 -2
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.js +67 -2
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/update.d.ts +48 -0
- package/dist/update.js +504 -0
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +9 -0
- package/templates/.claude/hooks/architecture-check.sh +8 -0
- package/templates/.claude/rules/loop-contract.md +129 -0
- package/templates/.claude/settings.json +3 -2
- package/templates/.env.example +13 -0
- package/templates/CLAUDE.md +1 -1
- package/templates/CONVENTIONS.md +3 -2
- package/templates/_gitignore +162 -0
- package/templates/apps/api/CLAUDE.md +14 -10
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +22 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
- package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
- package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
- package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
- package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +5 -2
- package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +21 -14
- package/templates/apps/api/src/middleware/auth.ts +8 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +290 -82
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
- package/templates/apps/api/src/routes/webhooks/stripe.ts +184 -6
- package/templates/apps/api/src/server.ts +14 -2
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/default-tenant.ts +12 -3
- package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/vercel.json +1 -1
- package/templates/apps/mcp/CLAUDE.md +19 -7
- package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
- package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
- package/templates/apps/mcp/src/tools/proposals.ts +27 -0
- package/templates/apps/mcp/src/workspace-server.ts +16 -0
- package/templates/apps/web/_gitignore +6 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
- package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +28 -83
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +27 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +102 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +3 -2
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
- package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +11 -2
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +5 -24
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +64 -18
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
- package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
- package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +5 -3
- package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
- package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
- package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
- package/templates/apps/web/app/[locale]/layout.tsx +23 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
- package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
- package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
- package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
- package/templates/apps/web/app/robots.txt/route.ts +13 -10
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
- package/templates/apps/web/lib/account-config.ts +31 -16
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/messages/en.json +67 -4
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +39 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
- package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
- package/templates/database/_gitignore +8 -0
- package/templates/database/inbox/schema.prisma +38 -12
- package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
- package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
- package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
- package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
- package/templates/database/ops/schema.prisma +16 -0
- package/templates/database/package.json +1 -1
- package/templates/project.yaml +6 -3
- package/versions.json +35 -35
- package/templates/.claude/rules/preserve-existing-style.md +0 -51
|
@@ -3,9 +3,12 @@
|
|
|
3
3
|
/**
|
|
4
4
|
* artifacts/artifact-actions.ts — the review write (#8234, spec
|
|
5
5
|
* docs/specs/8234-member-artifacts-account-artifacts-and-spec.md D6; the
|
|
6
|
-
* write is #8170's `POST /v1/member/artifacts/:pieceId/review`)
|
|
6
|
+
* write is #8170's `POST /v1/member/artifacts/:pieceId/review`) and a
|
|
7
|
+
* proposal's payment (#8516, spec
|
|
8
|
+
* docs/specs/8516-proposals-as-an-artifact-kind-spec.md D19; the write is
|
|
9
|
+
* `POST /v1/member/artifacts/:pieceId/payment`).
|
|
7
10
|
*
|
|
8
|
-
*
|
|
11
|
+
* `submitArtifactReview`, the review form's action, posts
|
|
9
12
|
* Approve or Request changes on a piece's latest version through
|
|
10
13
|
* `member.reviewArtifact` with `forwardedCookieInit()`'s init as built — the
|
|
11
14
|
* session cookie plus the request's `Origin` on a single site, the hub
|
|
@@ -15,22 +18,38 @@
|
|
|
15
18
|
* `pieceId` and `version` are untrusted: they are shape-checked here, and the
|
|
16
19
|
* API scopes them to the Person the credential resolves.
|
|
17
20
|
*
|
|
21
|
+
* A proposal's payment (#8516 D19). An approval whose answered artifact owes
|
|
22
|
+
* a payment (`paymentDue`) goes straight on to it; `payForProposal`, the Pay
|
|
23
|
+
* now form's action, does the same from the artifact page. Both run the one
|
|
24
|
+
* private `startProposalPayment`: `member.payArtifact` with the same init,
|
|
25
|
+
* then `redirect()` — only to a URL `isStripeCheckoutUrl` admits, so the
|
|
26
|
+
* client is never sent anywhere but Stripe's hosted Checkout page. The
|
|
27
|
+
* amount is the API's, from the stored proposal; nothing posted here names
|
|
28
|
+
* one. Any other outcome is `payment_failed`, and the review stays recorded,
|
|
29
|
+
* so the page offers Pay now. `redirect()` throws `NEXT_REDIRECT` by design,
|
|
30
|
+
* so it is called outside any try/catch (`../actions.ts`'s idiom).
|
|
31
|
+
*
|
|
18
32
|
* Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
|
|
19
33
|
* parity test reads that suffix, and the member writes have no MCP twin by
|
|
20
34
|
* decision (8170's header). The reads the pages share live in
|
|
21
35
|
* `artifact-data.ts`, never here — every export of this module is a POST
|
|
22
|
-
* endpoint. A log line carries `err.name` only — never the comment
|
|
36
|
+
* endpoint. A log line carries `err.name` only — never the comment, an id
|
|
37
|
+
* or a Checkout URL.
|
|
23
38
|
*/
|
|
24
39
|
|
|
25
40
|
import { member } from "@working-theory/api-client";
|
|
26
41
|
import {
|
|
42
|
+
isStripeCheckoutUrl,
|
|
27
43
|
memberReviewBodySchema,
|
|
28
44
|
memberReviewParamsSchema,
|
|
29
45
|
type MemberReviewBody,
|
|
30
46
|
} from "@working-theory/validation";
|
|
47
|
+
import { redirect } from "next/navigation";
|
|
31
48
|
|
|
32
49
|
import { forwardedCookieInit } from "../../../../../lib/server-api";
|
|
33
50
|
|
|
51
|
+
import { paymentDue } from "./artifact-data";
|
|
52
|
+
|
|
34
53
|
/** What the review form shows after a submit. */
|
|
35
54
|
export type ArtifactReviewState =
|
|
36
55
|
| { kind: "idle" }
|
|
@@ -42,7 +61,8 @@ export type ArtifactReviewState =
|
|
|
42
61
|
| "closed"
|
|
43
62
|
| "not_latest"
|
|
44
63
|
| "rate_limited"
|
|
45
|
-
| "failed"
|
|
64
|
+
| "failed"
|
|
65
|
+
| "payment_failed";
|
|
46
66
|
};
|
|
47
67
|
|
|
48
68
|
export type ArtifactReviewReason = Extract<
|
|
@@ -50,6 +70,12 @@ export type ArtifactReviewReason = Extract<
|
|
|
50
70
|
{ kind: "error" }
|
|
51
71
|
>["reason"];
|
|
52
72
|
|
|
73
|
+
/** What the Pay now form shows after a submit that did not leave for Stripe. */
|
|
74
|
+
export type ProposalPayState =
|
|
75
|
+
{ kind: "idle" } | { kind: "error"; reason: "payment_failed" };
|
|
76
|
+
|
|
77
|
+
const PAYMENT_FAILED = { kind: "error", reason: "payment_failed" } as const;
|
|
78
|
+
|
|
53
79
|
function error(reason: ArtifactReviewReason): ArtifactReviewState {
|
|
54
80
|
return { kind: "error", reason };
|
|
55
81
|
}
|
|
@@ -119,5 +145,40 @@ export async function submitArtifactReview(
|
|
|
119
145
|
return error("failed");
|
|
120
146
|
}
|
|
121
147
|
if (!res.ok) return error(refusalReason(res.status, res.body));
|
|
148
|
+
if (body.review === "approved" && paymentDue(res.body.artifact))
|
|
149
|
+
return startProposalPayment(pieceId);
|
|
122
150
|
return { kind: "reviewed" };
|
|
123
151
|
}
|
|
152
|
+
|
|
153
|
+
/** Pay now: open the approved proposal's payment and go to it. */
|
|
154
|
+
export async function payForProposal(
|
|
155
|
+
_prev: ProposalPayState,
|
|
156
|
+
formData: FormData,
|
|
157
|
+
): Promise<ProposalPayState> {
|
|
158
|
+
const pieceId = field(formData, "pieceId");
|
|
159
|
+
if (!memberReviewParamsSchema.safeParse({ pieceId }).success)
|
|
160
|
+
return PAYMENT_FAILED;
|
|
161
|
+
return startProposalPayment(pieceId);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Ask the API for the proposal's Stripe Checkout page and redirect there.
|
|
166
|
+
* Never returns on success; anything short of a Stripe Checkout URL is
|
|
167
|
+
* `payment_failed`.
|
|
168
|
+
*/
|
|
169
|
+
async function startProposalPayment(
|
|
170
|
+
pieceId: string,
|
|
171
|
+
): Promise<typeof PAYMENT_FAILED> {
|
|
172
|
+
let url: string | null = null;
|
|
173
|
+
try {
|
|
174
|
+
const res = await member.payArtifact(pieceId, await forwardedCookieInit());
|
|
175
|
+
if (res.ok && isStripeCheckoutUrl(res.body.url)) url = res.body.url;
|
|
176
|
+
} catch (err) {
|
|
177
|
+
console.error(
|
|
178
|
+
`[account] payment failed errName=${err instanceof Error ? err.name : "unknown"}`,
|
|
179
|
+
);
|
|
180
|
+
}
|
|
181
|
+
if (url === null) return PAYMENT_FAILED;
|
|
182
|
+
// Outside any try/catch on purpose — see the file header.
|
|
183
|
+
redirect(url);
|
|
184
|
+
}
|
|
@@ -64,6 +64,23 @@ export function isReviewable(a: MemberArtifact): boolean {
|
|
|
64
64
|
return a.approvedAt === null && a.versions[0]?.review === null;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
/**
|
|
68
|
+
* True for a proposal (#8516 D19). An older hub serves no `kind` (D4: the
|
|
69
|
+
* key is optional), and such an artifact is a deliverable.
|
|
70
|
+
*/
|
|
71
|
+
export function isProposal(a: MemberArtifact): boolean {
|
|
72
|
+
return a.kind === "proposal";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* True while an approved, priced proposal waits on the client's payment
|
|
77
|
+
* (#8516 D19). The API serves `due` only where a payment surface answers,
|
|
78
|
+
* so anything else — `null`, `paid` or an older hub's missing key — is not.
|
|
79
|
+
*/
|
|
80
|
+
export function paymentDue(a: MemberArtifact): boolean {
|
|
81
|
+
return a.payment?.status === "due";
|
|
82
|
+
}
|
|
83
|
+
|
|
67
84
|
/** The artifact page of one piece. */
|
|
68
85
|
export function artifactHref(locale: string, pieceId: string): string {
|
|
69
86
|
return localeHref(
|
|
@@ -18,6 +18,10 @@
|
|
|
18
18
|
* failed read renders today's error row with Retry and no frame (spec 7423
|
|
19
19
|
* D12). Nothing here tests the topology: the same file serves a single site
|
|
20
20
|
* and a hub-mode spoke.
|
|
21
|
+
*
|
|
22
|
+
* A proposal (#8516, spec docs/specs/8516-proposals-as-an-artifact-kind-spec.md
|
|
23
|
+
* D19) reads "Proposal · Version <n>" in its caption and Latest cell; a
|
|
24
|
+
* deliverable reads as before.
|
|
21
25
|
*/
|
|
22
26
|
|
|
23
27
|
import {
|
|
@@ -36,6 +40,7 @@ import { ErrorRow } from "../error-row";
|
|
|
36
40
|
import {
|
|
37
41
|
artifactHref,
|
|
38
42
|
artifactState,
|
|
43
|
+
isProposal,
|
|
39
44
|
loadArtifacts,
|
|
40
45
|
STATE_KEYS,
|
|
41
46
|
type ArtifactState,
|
|
@@ -120,14 +125,18 @@ export default async function AccountArtifactsPage({
|
|
|
120
125
|
timeZone: "UTC",
|
|
121
126
|
}).format(new Date(latest.deliveredAt));
|
|
122
127
|
const reviewWord = t(STATE_KEYS[state]);
|
|
128
|
+
const versionWord = t("artifacts.version", { version: latest.version });
|
|
129
|
+
const latestWord = isProposal(artifact)
|
|
130
|
+
? `${t("artifacts.proposal.caption")} · ${versionWord}`
|
|
131
|
+
: versionWord;
|
|
123
132
|
return {
|
|
124
133
|
id: artifact.id,
|
|
125
134
|
title: artifact.title,
|
|
126
135
|
href: artifactHref(locale, artifact.id),
|
|
127
136
|
facet: state,
|
|
128
|
-
caption: `${
|
|
137
|
+
caption: `${latestWord} · ${reviewWord}`,
|
|
129
138
|
cells: {
|
|
130
|
-
latest:
|
|
139
|
+
latest: latestWord,
|
|
131
140
|
delivered,
|
|
132
141
|
review: reviewWord,
|
|
133
142
|
},
|
|
@@ -3,19 +3,20 @@
|
|
|
3
3
|
* #8232 D2).
|
|
4
4
|
*
|
|
5
5
|
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
6
|
-
*
|
|
6
|
+
* settings frame (D7; five tabs since #8438 D14) — standalone, the Admin's
|
|
7
|
+
* shape. Renders the shared
|
|
7
8
|
* `AccountSettings` composition (@working-theory/templates/account, spec
|
|
8
9
|
* 8339 D4) — the same General form the Admin renders — through the client
|
|
9
10
|
* adapter `GeneralSettings` (`profile-form.tsx`). Below the form:
|
|
10
11
|
*
|
|
11
12
|
* - the contact details (`AccountContactDetails`, #8435 D13) — only on the
|
|
12
13
|
* member path, seeded from `member.contact` and saved by `saveContact`;
|
|
13
|
-
* - Request account deletion (`DeletionRequest`, #8232 D5);
|
|
14
14
|
* - any section a module contributes (`accountSettingsSections`);
|
|
15
15
|
* - `BillingAnchor`, which forwards a `#billing` fragment to Billing.
|
|
16
16
|
*
|
|
17
|
-
* Sign-in moved to Security
|
|
18
|
-
* C6)
|
|
17
|
+
* Sign-in moved to Security, cookie settings moved to Notifications then to
|
|
18
|
+
* Privacy (#8339 C6), and Request account deletion moved to Privacy (#8438
|
|
19
|
+
* D16) — General no longer renders any of the three.
|
|
19
20
|
*
|
|
20
21
|
* Whether the profile and deletion go through the member API is
|
|
21
22
|
* `loadProfileSource` (D3), never a topology test here.
|
|
@@ -28,7 +29,6 @@ import {
|
|
|
28
29
|
AccountSettingsPane,
|
|
29
30
|
type AccountContact,
|
|
30
31
|
} from "@working-theory/templates/account";
|
|
31
|
-
import { localeHref } from "@working-theory/ui";
|
|
32
32
|
import { Separator, Text } from "@working-theory/ui/primitives";
|
|
33
33
|
import type { Metadata } from "next";
|
|
34
34
|
import { getTranslations } from "next-intl/server";
|
|
@@ -41,7 +41,6 @@ import { memberSettingsFrame, SettingsSection, type Translate } from "../setting
|
|
|
41
41
|
|
|
42
42
|
import { BillingAnchor } from "./billing-anchor";
|
|
43
43
|
import { saveContact } from "./contact-actions";
|
|
44
|
-
import { DeletionRequest } from "./deletion-request";
|
|
45
44
|
import { GeneralSettings } from "./profile-form";
|
|
46
45
|
import { loadProfileSource } from "./profile-source";
|
|
47
46
|
|
|
@@ -164,24 +163,6 @@ export default async function AccountGeneralPage({ params }: Props): Promise<Rea
|
|
|
164
163
|
</div>
|
|
165
164
|
</>
|
|
166
165
|
) : null}
|
|
167
|
-
<Separator />
|
|
168
|
-
<div data-testid="account-deletion">
|
|
169
|
-
<DeletionRequest
|
|
170
|
-
via={source.via}
|
|
171
|
-
supportHref={localeHref(locale, "/account/support")}
|
|
172
|
-
labels={{
|
|
173
|
-
lead: t("settings.privacy.deletionLead"),
|
|
174
|
-
action: t("settings.privacy.deletion"),
|
|
175
|
-
sentSite: t("settings.privacy.deletionSentSite"),
|
|
176
|
-
sentHub: t("settings.privacy.deletionSentHub"),
|
|
177
|
-
unverified: t("settings.privacy.deletionUnverified"),
|
|
178
|
-
unavailable: t("settings.privacy.deletionUnavailable"),
|
|
179
|
-
error: t("settings.privacy.deletionError"),
|
|
180
|
-
viaSupport: t("settings.privacy.deletionViaSupport"),
|
|
181
|
-
supportLink: t("settings.privacy.deletionSupportLink"),
|
|
182
|
-
}}
|
|
183
|
-
/>
|
|
184
|
-
</div>
|
|
185
166
|
{accountSettingsSections.map((section) => (
|
|
186
167
|
<SettingsSection key={section.id} id={section.id} title={ctx.t(section.title)}>
|
|
187
168
|
{section.render(ctx)}
|
|
@@ -1,23 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* /[locale]/account/notifications — User settings › Notifications (#8339
|
|
3
|
-
* D8
|
|
3
|
+
* D8; #8438 D15 — what we send you).
|
|
4
4
|
*
|
|
5
5
|
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* settings frame (D7) — standalone, the Admin's shape. Always renders
|
|
7
|
+
* (decision A, operator 2026-10-12): a `ConsentSwitchList` of the site
|
|
8
|
+
* registry's `area: "communications"` purposes (`consent` and
|
|
9
|
+
* `preference`), each read from `GET /v1/member/consents`'s OWN state and
|
|
10
|
+
* `effective` default (no row ⇒ on for a preference, off for a consent).
|
|
11
|
+
* With none declared, one `account-notifications-empty` line.
|
|
12
|
+
*
|
|
13
|
+
* The cookie-settings link moved to Privacy (#8438 D14, C6) — this tab no
|
|
14
|
+
* longer renders it.
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
|
|
17
|
+
import { member } from "@working-theory/api-client";
|
|
18
|
+
import {
|
|
19
|
+
AccountSettingsFrame,
|
|
20
|
+
AccountSettingsPane,
|
|
21
|
+
ConsentSwitchList,
|
|
22
|
+
type ConsentSwitchRow,
|
|
23
|
+
} from "@working-theory/templates/account";
|
|
14
24
|
import type { Metadata } from "next";
|
|
15
25
|
import { getTranslations } from "next-intl/server";
|
|
16
26
|
import type { ReactElement } from "react";
|
|
17
27
|
|
|
18
28
|
import { accountSession } from "../account-data";
|
|
29
|
+
import { setConsentAction } from "../privacy/actions";
|
|
19
30
|
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
20
31
|
|
|
32
|
+
|
|
21
33
|
interface Props {
|
|
22
34
|
params: Promise<{ locale: string }>;
|
|
23
35
|
}
|
|
@@ -25,7 +37,7 @@ interface Props {
|
|
|
25
37
|
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
26
38
|
const { locale } = await params;
|
|
27
39
|
const t = await getTranslations({ locale, namespace: "account" });
|
|
28
|
-
return { title: t("settings.notifications.title"), description: t("settings.
|
|
40
|
+
return { title: t("settings.notifications.title"), description: t("settings.notifications.lead") };
|
|
29
41
|
}
|
|
30
42
|
|
|
31
43
|
export default async function AccountNotificationsPage({ params }: Props): Promise<ReactElement> {
|
|
@@ -35,23 +47,57 @@ export default async function AccountNotificationsPage({ params }: Props): Promi
|
|
|
35
47
|
const frame = memberSettingsFrame(locale, t as unknown as Translate, "notifications");
|
|
36
48
|
const name = session?.ctx.user.name || session?.ctx.user.email || "";
|
|
37
49
|
|
|
50
|
+
let rows: ConsentSwitchRow[] = [];
|
|
51
|
+
let unavailable = false;
|
|
52
|
+
if (session) {
|
|
53
|
+
try {
|
|
54
|
+
const res = await member.consents(session.ctx.init, { locale });
|
|
55
|
+
if (res.ok) {
|
|
56
|
+
rows = res.body.purposes
|
|
57
|
+
.filter((purpose) => purpose.area === "communications")
|
|
58
|
+
.map((purpose) => ({
|
|
59
|
+
id: purpose.id,
|
|
60
|
+
label: purpose.statement,
|
|
61
|
+
granted: purpose.effective,
|
|
62
|
+
...(purpose.description ? { description: purpose.description } : {}),
|
|
63
|
+
...(purpose.state
|
|
64
|
+
? {
|
|
65
|
+
dateLine: purpose.state.granted
|
|
66
|
+
? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
|
|
67
|
+
: t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
|
|
68
|
+
}
|
|
69
|
+
: {}),
|
|
70
|
+
}));
|
|
71
|
+
} else {
|
|
72
|
+
unavailable = true;
|
|
73
|
+
}
|
|
74
|
+
} catch {
|
|
75
|
+
unavailable = true;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
38
79
|
return (
|
|
39
80
|
<AccountSettingsFrame {...frame} name={name}>
|
|
40
81
|
<AccountSettingsPane
|
|
41
82
|
id="account-settings-notifications"
|
|
42
83
|
title={t("settings.notifications.title")}
|
|
43
|
-
lead={t("settings.
|
|
84
|
+
lead={t("settings.notifications.lead")}
|
|
44
85
|
>
|
|
45
|
-
|
|
46
|
-
<
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
{t("settings.
|
|
52
|
-
|
|
53
|
-
|
|
86
|
+
{unavailable ? (
|
|
87
|
+
<span data-testid="account-notifications-unavailable">{t("settings.privacy.unavailable")}</span>
|
|
88
|
+
) : (
|
|
89
|
+
<ConsentSwitchList
|
|
90
|
+
rows={rows}
|
|
91
|
+
onChange={setConsentAction}
|
|
92
|
+
emptyLine={t("settings.notifications.empty")}
|
|
93
|
+
testIdPrefix="account-notifications"
|
|
94
|
+
/>
|
|
95
|
+
)}
|
|
54
96
|
</AccountSettingsPane>
|
|
55
97
|
</AccountSettingsFrame>
|
|
56
98
|
);
|
|
57
99
|
}
|
|
100
|
+
|
|
101
|
+
function formatDate(iso: string, locale: string): string {
|
|
102
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
|
|
103
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* account/onboarding/actions.ts — the onboarding page's read and write
|
|
5
|
+
* (#8515, spec docs/specs/8515-standard-client-onboarding-diagnostic-and-spec.md
|
|
6
|
+
* D12; the routes are D7's `GET` and D8's `POST /v1/member/onboarding`).
|
|
7
|
+
*
|
|
8
|
+
* - `readOnboardingForm` — `member.onboarding`, the standard onboarding
|
|
9
|
+
* magnet's questions, or `form: null` while it is not open to the
|
|
10
|
+
* caller. A 404 (no member area on this build, or a hub that does not
|
|
11
|
+
* admit the route) is that same "not open" state; any other refusal is
|
|
12
|
+
* `error`, so the page shows its error row with Retry.
|
|
13
|
+
* - `submitOnboarding` — `member.submitOnboarding` with the answers.
|
|
14
|
+
*
|
|
15
|
+
* Both send `forwardedCookieInit()`'s init as built — the session cookie
|
|
16
|
+
* plus the request's `Origin` on a single site, the hub credential on a
|
|
17
|
+
* hub-mode spoke — so nothing here tests the topology, and no identity is
|
|
18
|
+
* posted: the API takes the Person from the credential.
|
|
19
|
+
*
|
|
20
|
+
* The answers are the entries named `answer.<name>`; a repeated name becomes
|
|
21
|
+
* an array, and so does a name the form lists under `multi` (a multi
|
|
22
|
+
* question with one box checked still posts an array, which is what the API
|
|
23
|
+
* asks of it). A value is sent as typed; one left empty or all-space is
|
|
24
|
+
* omitted, because the API refuses `""`. The API checks every answer
|
|
25
|
+
* against the form it serves — nothing here is trusted.
|
|
26
|
+
*
|
|
27
|
+
* Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
|
|
28
|
+
* parity test reads that suffix, and the member routes have no MCP twin by
|
|
29
|
+
* decision (8515 C4). Logs carry the function, the status and the code only
|
|
30
|
+
* — never an answer or a credential.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { member } from "@working-theory/api-client";
|
|
34
|
+
import type { MemberOnboardingFormResponse } from "@working-theory/validation";
|
|
35
|
+
|
|
36
|
+
import { forwardedCookieInit } from "../../../../../lib/server-api";
|
|
37
|
+
|
|
38
|
+
/** The refusal codes the form has its own words for; anything else is `failed`. */
|
|
39
|
+
const KNOWN_CODES = [
|
|
40
|
+
"onboarding_off",
|
|
41
|
+
"answers_invalid",
|
|
42
|
+
"link_not_https",
|
|
43
|
+
"too_many_requests",
|
|
44
|
+
] as const;
|
|
45
|
+
|
|
46
|
+
export type OnboardingErrorCode = (typeof KNOWN_CODES)[number] | "failed";
|
|
47
|
+
|
|
48
|
+
/** What the form shows after a submit. */
|
|
49
|
+
export type OnboardingState =
|
|
50
|
+
| { kind: "idle" }
|
|
51
|
+
| { kind: "sent" }
|
|
52
|
+
| { kind: "error"; code: OnboardingErrorCode };
|
|
53
|
+
|
|
54
|
+
export type OnboardingForm = NonNullable<MemberOnboardingFormResponse["form"]>;
|
|
55
|
+
|
|
56
|
+
export type OnboardingRead = { kind: "ok"; form: OnboardingForm | null } | { kind: "error" };
|
|
57
|
+
|
|
58
|
+
/** PII-safe error identity for logs (`err.name` only). */
|
|
59
|
+
function errorName(err: unknown): string {
|
|
60
|
+
return err instanceof Error ? err.name : "unknown";
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `error.code` of a member refusal body, when it has one. */
|
|
64
|
+
function errorCode(body: unknown): string | null {
|
|
65
|
+
const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
|
|
66
|
+
return typeof code === "string" ? code : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function knownCode(status: number, code: string | null): OnboardingErrorCode {
|
|
70
|
+
const known = KNOWN_CODES.find((candidate) => candidate === code);
|
|
71
|
+
if (known) return known;
|
|
72
|
+
// The hub's per-person budget and the per-IP limiter answer `429 rate_limited`.
|
|
73
|
+
return status === 429 ? "too_many_requests" : "failed";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export async function readOnboardingForm(): Promise<OnboardingRead> {
|
|
77
|
+
try {
|
|
78
|
+
const res = await member.onboarding(await forwardedCookieInit());
|
|
79
|
+
if (res.ok) return { kind: "ok", form: res.body.form };
|
|
80
|
+
if (res.status === 404) return { kind: "ok", form: null };
|
|
81
|
+
console.error(
|
|
82
|
+
`[onboarding] readOnboardingForm status=${res.status} code=${errorCode(res.body) ?? "none"}`,
|
|
83
|
+
);
|
|
84
|
+
} catch (err) {
|
|
85
|
+
console.error(`[onboarding] readOnboardingForm errName=${errorName(err)}`);
|
|
86
|
+
}
|
|
87
|
+
return { kind: "error" };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** `(prev, formData)` for `useActionState`. */
|
|
91
|
+
export async function submitOnboarding(
|
|
92
|
+
_prev: OnboardingState,
|
|
93
|
+
formData: FormData,
|
|
94
|
+
): Promise<OnboardingState> {
|
|
95
|
+
// Action input is untrusted: a hand-built call may post anything.
|
|
96
|
+
if (!(formData instanceof FormData)) return { kind: "error", code: "failed" };
|
|
97
|
+
|
|
98
|
+
const multi = new Set(formData.getAll("multi").filter((v) => typeof v === "string"));
|
|
99
|
+
const answers: Record<string, string | string[]> = {};
|
|
100
|
+
for (const key of new Set(formData.keys())) {
|
|
101
|
+
if (!key.startsWith("answer.")) continue;
|
|
102
|
+
const name = key.slice("answer.".length);
|
|
103
|
+
const values = formData
|
|
104
|
+
.getAll(key)
|
|
105
|
+
.filter((v): v is string => typeof v === "string" && v.trim() !== "");
|
|
106
|
+
if (values.length === 0) continue;
|
|
107
|
+
answers[name] = values.length > 1 || multi.has(name) ? values : values[0]!;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try {
|
|
111
|
+
const res = await member.submitOnboarding({ answers }, await forwardedCookieInit());
|
|
112
|
+
if (res.ok) return { kind: "sent" };
|
|
113
|
+
const code = knownCode(res.status, errorCode(res.body));
|
|
114
|
+
console.error(`[onboarding] submitOnboarding status=${res.status} code=${code}`);
|
|
115
|
+
return { kind: "error", code };
|
|
116
|
+
} catch (err) {
|
|
117
|
+
console.error(`[onboarding] submitOnboarding errName=${errorName(err)}`);
|
|
118
|
+
return { kind: "error", code: "failed" };
|
|
119
|
+
}
|
|
120
|
+
}
|