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
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* /[locale]/account/privacy — User settings › Privacy (#8438 D14, NEW).
|
|
3
|
+
*
|
|
4
|
+
* RSC, outside the account layout's SignedInShell (#8430), in the member's
|
|
5
|
+
* settings frame (D7) — standalone, the Admin's shape. In DOM order:
|
|
6
|
+
*
|
|
7
|
+
* 1. account-privacy-summary — one line with a link to the site's
|
|
8
|
+
* privacy policy.
|
|
9
|
+
* 2. account-privacy-cookies — the current cookie-banner choice, read
|
|
10
|
+
* from the request's OWN `ailk-consent` cookie (never the ledger —
|
|
11
|
+
* the cookie stays the operative switch, C3), and a Change link
|
|
12
|
+
* (moved here from Notifications, #8438 D15) to
|
|
13
|
+
* `/privacy#cookie-settings`.
|
|
14
|
+
* 3. account-privacy-consents — one `ConsentSwitchList` row per
|
|
15
|
+
* `area: "data"` purpose, or the "no other data-use consents" line.
|
|
16
|
+
* 4. account-privacy-agreements — a read-only `AgreementList` of every
|
|
17
|
+
* agreement (`service_terms`, `account_terms`, …), or "None yet".
|
|
18
|
+
* 5. account-deletion — moved unchanged from General (#8438 D16).
|
|
19
|
+
*
|
|
20
|
+
* Sections 3 and 4 read `GET /v1/member/consents`; a failed read shows one
|
|
21
|
+
* line in their place and sections 1, 2 and 5 still render (D14).
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import { member } from "@working-theory/api-client";
|
|
25
|
+
import {
|
|
26
|
+
AccountSettingsFrame,
|
|
27
|
+
AccountSettingsPane,
|
|
28
|
+
AgreementList,
|
|
29
|
+
ConsentSwitchList,
|
|
30
|
+
} from "@working-theory/templates/account";
|
|
31
|
+
import { localeHref } from "@working-theory/ui";
|
|
32
|
+
import { Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
33
|
+
import type { Metadata } from "next";
|
|
34
|
+
import { cookies } from "next/headers";
|
|
35
|
+
import { getTranslations } from "next-intl/server";
|
|
36
|
+
import type { ReactElement } from "react";
|
|
37
|
+
|
|
38
|
+
import { accountSession } from "../account-data";
|
|
39
|
+
import { DeletionRequest } from "../general/deletion-request";
|
|
40
|
+
import { loadProfileSource } from "../general/profile-source";
|
|
41
|
+
import { memberSettingsFrame, type Translate } from "../settings-frame";
|
|
42
|
+
|
|
43
|
+
import { setConsentAction } from "./actions";
|
|
44
|
+
|
|
45
|
+
/** The cookie banner's own choice cookie (`layout.tsx`'s `CONSENT_COOKIE_NAME`, #5557). */
|
|
46
|
+
const CONSENT_COOKIE_NAME = "ailk-consent";
|
|
47
|
+
|
|
48
|
+
interface Props {
|
|
49
|
+
params: Promise<{ locale: string }>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function generateMetadata({ params }: Props): Promise<Metadata> {
|
|
53
|
+
const { locale } = await params;
|
|
54
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
55
|
+
return { title: t("settings.privacy.title"), description: t("settings.privacy.lead") };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export default async function AccountPrivacyPage({ params }: Props): Promise<ReactElement> {
|
|
59
|
+
const { locale } = await params;
|
|
60
|
+
const t = await getTranslations({ locale, namespace: "account" });
|
|
61
|
+
const session = await accountSession(locale);
|
|
62
|
+
const frame = memberSettingsFrame(locale, t as unknown as Translate, "privacy");
|
|
63
|
+
|
|
64
|
+
// Unreachable behind the (authed) gate; a session that expired between
|
|
65
|
+
// the layout and the page renders the pane's title alone.
|
|
66
|
+
if (!session) {
|
|
67
|
+
return (
|
|
68
|
+
<AccountSettingsFrame {...frame} name="">
|
|
69
|
+
<AccountSettingsPane
|
|
70
|
+
id="account-settings-privacy"
|
|
71
|
+
title={t("settings.privacy.title")}
|
|
72
|
+
lead={t("settings.privacy.lead")}
|
|
73
|
+
>
|
|
74
|
+
{null}
|
|
75
|
+
</AccountSettingsPane>
|
|
76
|
+
</AccountSettingsFrame>
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { ctx } = session;
|
|
81
|
+
const name = ctx.user.name || ctx.user.email;
|
|
82
|
+
const source = await loadProfileSource(ctx);
|
|
83
|
+
|
|
84
|
+
let cookieChoice: "accepted" | "declined" | null;
|
|
85
|
+
try {
|
|
86
|
+
const value = (await cookies()).get(CONSENT_COOKIE_NAME)?.value;
|
|
87
|
+
cookieChoice = value === "accepted" || value === "declined" ? value : null;
|
|
88
|
+
} catch {
|
|
89
|
+
cookieChoice = null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let dataRows: { id: string; label: string; description?: string; granted: boolean; dateLine?: string }[] = [];
|
|
93
|
+
let agreementRows: { id: string; statement: string; version: number; acceptedLine?: string }[] = [];
|
|
94
|
+
let consentsAvailable = true;
|
|
95
|
+
try {
|
|
96
|
+
const res = await member.consents(ctx.init, { locale });
|
|
97
|
+
if (res.ok) {
|
|
98
|
+
dataRows = res.body.purposes
|
|
99
|
+
.filter((purpose) => purpose.area === "data")
|
|
100
|
+
.map((purpose) => ({
|
|
101
|
+
id: purpose.id,
|
|
102
|
+
label: purpose.statement,
|
|
103
|
+
granted: purpose.effective,
|
|
104
|
+
...(purpose.description ? { description: purpose.description } : {}),
|
|
105
|
+
...(purpose.state
|
|
106
|
+
? {
|
|
107
|
+
dateLine: purpose.state.granted
|
|
108
|
+
? t("settings.privacy.given", { date: formatDate(purpose.state.at, locale) })
|
|
109
|
+
: t("settings.privacy.withdrawn", { date: formatDate(purpose.state.at, locale) }),
|
|
110
|
+
}
|
|
111
|
+
: {}),
|
|
112
|
+
}));
|
|
113
|
+
agreementRows = res.body.purposes
|
|
114
|
+
.filter((purpose) => purpose.kind === "agreement")
|
|
115
|
+
.map((purpose) => ({
|
|
116
|
+
id: purpose.id,
|
|
117
|
+
statement: purpose.statement,
|
|
118
|
+
version: purpose.policyVersion,
|
|
119
|
+
...(purpose.state?.granted
|
|
120
|
+
? { acceptedLine: t("settings.privacy.accepted", { date: formatDate(purpose.state.at, locale) }) }
|
|
121
|
+
: {}),
|
|
122
|
+
}));
|
|
123
|
+
} else {
|
|
124
|
+
consentsAvailable = false;
|
|
125
|
+
}
|
|
126
|
+
} catch {
|
|
127
|
+
consentsAvailable = false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return (
|
|
131
|
+
<AccountSettingsFrame {...frame} name={name}>
|
|
132
|
+
<AccountSettingsPane
|
|
133
|
+
id="account-settings-privacy"
|
|
134
|
+
title={t("settings.privacy.title")}
|
|
135
|
+
lead={t("settings.privacy.lead")}
|
|
136
|
+
>
|
|
137
|
+
<div data-testid="account-privacy-summary" className="flex flex-wrap items-baseline gap-1">
|
|
138
|
+
<Text as="span" variant="body-sm">
|
|
139
|
+
{t("settings.privacy.summary")}
|
|
140
|
+
</Text>
|
|
141
|
+
<Link href={localeHref(locale, "/privacy")} data-testid="account-privacy-policy">
|
|
142
|
+
{t("settings.privacy.policyLink")}
|
|
143
|
+
</Link>
|
|
144
|
+
</div>
|
|
145
|
+
|
|
146
|
+
<div data-testid="account-privacy-cookies" className="flex flex-col gap-1">
|
|
147
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
148
|
+
{t("settings.privacy.cookiesTitle")}
|
|
149
|
+
</Heading>
|
|
150
|
+
<div className="flex flex-wrap items-baseline gap-3">
|
|
151
|
+
<Text as="span" variant="body-sm" tone="muted">
|
|
152
|
+
{cookieChoice === "accepted"
|
|
153
|
+
? t("settings.privacy.cookiesAccepted")
|
|
154
|
+
: cookieChoice === "declined"
|
|
155
|
+
? t("settings.privacy.cookiesDeclined")
|
|
156
|
+
: t("settings.privacy.cookiesUnset")}
|
|
157
|
+
</Text>
|
|
158
|
+
<Link
|
|
159
|
+
href={localeHref(locale, "/privacy#cookie-settings")}
|
|
160
|
+
className="inline-flex min-h-6 items-center"
|
|
161
|
+
data-testid="account-cookie-settings"
|
|
162
|
+
>
|
|
163
|
+
{t("settings.privacy.cookiesChange")}
|
|
164
|
+
</Link>
|
|
165
|
+
</div>
|
|
166
|
+
</div>
|
|
167
|
+
|
|
168
|
+
{consentsAvailable ? (
|
|
169
|
+
<>
|
|
170
|
+
<div data-testid="account-privacy-consents" className="flex flex-col gap-2">
|
|
171
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
172
|
+
{t("settings.privacy.consentsTitle")}
|
|
173
|
+
</Heading>
|
|
174
|
+
<ConsentSwitchList
|
|
175
|
+
rows={dataRows}
|
|
176
|
+
onChange={setConsentAction}
|
|
177
|
+
emptyLine={t("settings.privacy.consentsEmpty")}
|
|
178
|
+
testIdPrefix="account-privacy-data-consent"
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<div data-testid="account-privacy-agreements" className="flex flex-col gap-2">
|
|
183
|
+
<Heading level={2} visual={4} className="!text-body-sm !font-medium">
|
|
184
|
+
{t("settings.privacy.agreementsTitle")}
|
|
185
|
+
</Heading>
|
|
186
|
+
<AgreementList
|
|
187
|
+
rows={agreementRows}
|
|
188
|
+
notAcceptedLine={t("settings.privacy.agreementsEmpty")}
|
|
189
|
+
emptyLine={t("settings.privacy.agreementsEmpty")}
|
|
190
|
+
testIdPrefix="account-privacy-agreement"
|
|
191
|
+
/>
|
|
192
|
+
</div>
|
|
193
|
+
</>
|
|
194
|
+
) : (
|
|
195
|
+
<span data-testid="account-privacy-unavailable">
|
|
196
|
+
<Text as="p" variant="body-sm" tone="muted">
|
|
197
|
+
{t("settings.privacy.unavailable")}
|
|
198
|
+
</Text>
|
|
199
|
+
</span>
|
|
200
|
+
)}
|
|
201
|
+
|
|
202
|
+
<div data-testid="account-deletion">
|
|
203
|
+
<DeletionRequest
|
|
204
|
+
via={source.via}
|
|
205
|
+
supportHref={localeHref(locale, "/account/support")}
|
|
206
|
+
labels={{
|
|
207
|
+
lead: t("settings.privacy.deletionLead"),
|
|
208
|
+
action: t("settings.privacy.deletion"),
|
|
209
|
+
sentSite: t("settings.privacy.deletionSentSite"),
|
|
210
|
+
sentHub: t("settings.privacy.deletionSentHub"),
|
|
211
|
+
unverified: t("settings.privacy.deletionUnverified"),
|
|
212
|
+
unavailable: t("settings.privacy.deletionUnavailable"),
|
|
213
|
+
error: t("settings.privacy.deletionError"),
|
|
214
|
+
viaSupport: t("settings.privacy.deletionViaSupport"),
|
|
215
|
+
supportLink: t("settings.privacy.deletionSupportLink"),
|
|
216
|
+
}}
|
|
217
|
+
/>
|
|
218
|
+
</div>
|
|
219
|
+
</AccountSettingsPane>
|
|
220
|
+
</AccountSettingsFrame>
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function formatDate(iso: string, locale: string): string {
|
|
225
|
+
return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(new Date(iso));
|
|
226
|
+
}
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
import type { AccountSettingsFrameProps, AccountSettingsTab } from "@working-theory/templates/account";
|
|
26
26
|
import { localeHref } from "@working-theory/ui";
|
|
27
27
|
import { Heading, Icon, Text } from "@working-theory/ui/primitives";
|
|
28
|
-
import { Bell, CreditCard, ShieldCheck, UserRound } from "lucide-react";
|
|
28
|
+
import { Bell, CreditCard, Lock, ShieldCheck, UserRound } from "lucide-react";
|
|
29
29
|
import type { ReactElement, ReactNode } from "react";
|
|
30
30
|
|
|
31
31
|
/** The `next-intl` translator General/Security/Notifications/Billing each
|
|
@@ -36,12 +36,14 @@ import type { ReactElement, ReactNode } from "react";
|
|
|
36
36
|
* own `t` with `as unknown as Translate`. */
|
|
37
37
|
export type Translate = (key: string, values?: Record<string, unknown>) => string;
|
|
38
38
|
|
|
39
|
-
/** User settings' tabs, in the reference's order (#8339 D7
|
|
40
|
-
* route that renders outside the
|
|
39
|
+
/** User settings' tabs, in the reference's order (#8339 D7; #8438 D14 adds
|
|
40
|
+
* Privacy before Billing) — each one a route that renders outside the
|
|
41
|
+
* account shell (#8430, #8441). */
|
|
41
42
|
export const SETTINGS_ROUTES = [
|
|
42
43
|
{ key: "general", path: "/account/general", titleKey: "settings.general.title", icon: UserRound },
|
|
43
44
|
{ key: "security", path: "/account/security", titleKey: "settings.security.title", icon: ShieldCheck },
|
|
44
45
|
{ key: "notifications", path: "/account/notifications", titleKey: "settings.notifications.title", icon: Bell },
|
|
46
|
+
{ key: "privacy", path: "/account/privacy", titleKey: "settings.privacy.title", icon: Lock },
|
|
45
47
|
{ key: "billing", path: "/account/billing", titleKey: "settings.billing.title", icon: CreditCard },
|
|
46
48
|
] as const;
|
|
47
49
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* ConsentBanner — a thin client wrapper around `Banner` (#8438 D12).
|
|
5
|
+
*
|
|
6
|
+
* `layout.tsx` (a Server Component) cannot pass `acceptCookiesAction` /
|
|
7
|
+
* `declineCookiesAction` straight into `Banner`'s `onAccept`/`onReject`
|
|
8
|
+
* props: those are plain functions, not JSX, and a function crossing the
|
|
9
|
+
* RSC → Client boundary as a PROP (rather than being called from inside
|
|
10
|
+
* client code) is the exact class of crash `signed-in-nav-rsc-boundary.
|
|
11
|
+
* test.tsx` guards against (#7402's `renderFlowTrigger` idiom next door
|
|
12
|
+
* solves the same problem the same way). This component is itself
|
|
13
|
+
* "use client" and imports the two Server Actions directly, so nothing
|
|
14
|
+
* callable crosses from the server — only the serializable props below do.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { Banner, type BannerProps } from "@working-theory/ui/chrome";
|
|
18
|
+
import type { ReactElement } from "react";
|
|
19
|
+
|
|
20
|
+
import { acceptCookiesAction, declineCookiesAction } from "./consent.actions";
|
|
21
|
+
|
|
22
|
+
export function ConsentBanner(
|
|
23
|
+
props: Omit<BannerProps, "onAccept" | "onReject">,
|
|
24
|
+
): ReactElement {
|
|
25
|
+
return (
|
|
26
|
+
<Banner
|
|
27
|
+
{...props}
|
|
28
|
+
onAccept={() => void acceptCookiesAction()}
|
|
29
|
+
onReject={() => void declineCookiesAction()}
|
|
30
|
+
/>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* acceptCookiesAction / declineCookiesAction — the cookie banner's two
|
|
5
|
+
* Server Actions (#8438 D12, spec
|
|
6
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md).
|
|
7
|
+
*
|
|
8
|
+
* Bound to Banner's `onAccept`/`onReject` in `layout.tsx`. Each ensures the
|
|
9
|
+
* visitor id (`consent-visitor.ts`), then calls `consent.recordCookieChoice`
|
|
10
|
+
* through `@working-theory/api-client` with the site's own spoke key (added
|
|
11
|
+
* by `http.ts`) and the visitor-forward headers a spoke attaches today.
|
|
12
|
+
*
|
|
13
|
+
* Neither ever throws to the client — Banner's `onAccept`/`onReject` are
|
|
14
|
+
* plain `() => void`, and the banner island already hides itself locally;
|
|
15
|
+
* a failed write here is logged and nothing more. Neither writes
|
|
16
|
+
* `ailk-consent` — the island owns that cookie, unchanged (C3).
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { consent, visitorForwardHeaders } from "@working-theory/api-client";
|
|
20
|
+
import { cookies, headers } from "next/headers";
|
|
21
|
+
import { getLocale } from "next-intl/server";
|
|
22
|
+
|
|
23
|
+
import { ensureConsentVisitor } from "../../lib/consent-visitor";
|
|
24
|
+
|
|
25
|
+
async function recordChoice(choice: "accepted" | "declined"): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
const cookieStore = await cookies();
|
|
28
|
+
const visitorId = ensureConsentVisitor(cookieStore);
|
|
29
|
+
|
|
30
|
+
let locale = "en";
|
|
31
|
+
try {
|
|
32
|
+
locale = await getLocale();
|
|
33
|
+
} catch {
|
|
34
|
+
// No request-scoped locale (e.g. outside next-intl's pipeline) — "en".
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
38
|
+
try {
|
|
39
|
+
const forwarded = visitorForwardHeaders(
|
|
40
|
+
await headers(),
|
|
41
|
+
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
42
|
+
);
|
|
43
|
+
if (forwarded) init = { headers: forwarded };
|
|
44
|
+
} catch {
|
|
45
|
+
// No request scope to read — record with no forwarding headers.
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const body = { visitorId, choice, locale };
|
|
49
|
+
const res = init
|
|
50
|
+
? await consent.recordCookieChoice(body, init)
|
|
51
|
+
: await consent.recordCookieChoice(body);
|
|
52
|
+
if (!res.ok) {
|
|
53
|
+
console.error("[consent.actions] record cookie choice refused", {
|
|
54
|
+
outcome: res.status,
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
// Never attach the visitor id or any request detail to the log line.
|
|
59
|
+
console.error("[consent.actions] record cookie choice failed", {
|
|
60
|
+
outcome: err instanceof Error ? err.name : "unknown",
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function acceptCookiesAction(): Promise<void> {
|
|
66
|
+
await recordChoice("accepted");
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export async function declineCookiesAction(): Promise<void> {
|
|
70
|
+
await recordChoice("declined");
|
|
71
|
+
}
|
|
@@ -48,7 +48,9 @@
|
|
|
48
48
|
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
49
49
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
50
50
|
import { submitLeadInputSchema } from "@working-theory/validation";
|
|
51
|
-
import { headers } from "next/headers";
|
|
51
|
+
import { cookies, headers } from "next/headers";
|
|
52
|
+
|
|
53
|
+
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
52
54
|
|
|
53
55
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
54
56
|
|
|
@@ -151,6 +153,16 @@ export async function captureLeadAction(
|
|
|
151
153
|
// No request scope to read — submit without forwarding.
|
|
152
154
|
}
|
|
153
155
|
|
|
156
|
+
// #8438 D12 — the banner's visitor id, so a later form submit links its
|
|
157
|
+
// cookie choices to the client record this submit creates or resolves
|
|
158
|
+
// (D11). Absent or invalid → no header, exactly as before this unit.
|
|
159
|
+
try {
|
|
160
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
161
|
+
if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
|
|
162
|
+
} catch {
|
|
163
|
+
// No request scope to read — submit with no visitor-link header.
|
|
164
|
+
}
|
|
165
|
+
|
|
154
166
|
try {
|
|
155
167
|
const res = init ? await leads.submit(body, init) : await leads.submit(body);
|
|
156
168
|
if (!res.ok) {
|
|
@@ -35,7 +35,9 @@ import {
|
|
|
35
35
|
flowSlugSchema,
|
|
36
36
|
submitFlowStepInputSchema,
|
|
37
37
|
} from "@working-theory/validation";
|
|
38
|
-
import { headers } from "next/headers";
|
|
38
|
+
import { cookies, headers } from "next/headers";
|
|
39
|
+
|
|
40
|
+
import { consentVisitorHeader, readConsentVisitor } from "../../../../lib/consent-visitor";
|
|
39
41
|
|
|
40
42
|
/**
|
|
41
43
|
* Reads the visitor's real IP from THIS action's own incoming request —
|
|
@@ -60,6 +62,22 @@ async function resolveVisitorIpInit(): Promise<
|
|
|
60
62
|
}
|
|
61
63
|
}
|
|
62
64
|
|
|
65
|
+
/**
|
|
66
|
+
* #8438 D12 — the banner's visitor id, so a magnet's step submit links its
|
|
67
|
+
* cookie choices to the Person lead-routing resolves (D11). `undefined`
|
|
68
|
+
* with no request scope, no cookie, or an invalid value.
|
|
69
|
+
*/
|
|
70
|
+
async function resolveConsentVisitorHeader(): Promise<
|
|
71
|
+
Record<string, string> | undefined
|
|
72
|
+
> {
|
|
73
|
+
try {
|
|
74
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
75
|
+
return visitorId ? consentVisitorHeader(visitorId) : undefined;
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
63
81
|
export async function submitFlowStepAction(
|
|
64
82
|
slug: string,
|
|
65
83
|
payload: {
|
|
@@ -102,12 +120,16 @@ export async function submitFlowStepAction(
|
|
|
102
120
|
...(locale !== undefined ? { locale } : {}),
|
|
103
121
|
};
|
|
104
122
|
const visitorIpInit = await resolveVisitorIpInit();
|
|
123
|
+
const consentVisitor = await resolveConsentVisitorHeader();
|
|
124
|
+
const mergedHeaders = { ...(visitorIpInit?.headers ?? {}), ...(consentVisitor ?? {}) };
|
|
125
|
+
const stepInit =
|
|
126
|
+
Object.keys(mergedHeaders).length > 0 ? { headers: mergedHeaders } : undefined;
|
|
105
127
|
// Two call shapes, not one call + a conditional spread: this keeps the
|
|
106
128
|
// call at EXACTLY 2 args when there's no header to forward (never an
|
|
107
129
|
// explicit `undefined` 3rd arg) — collapsing it into a spread-args form
|
|
108
130
|
// would silently change call arity for every existing caller/test.
|
|
109
|
-
const res =
|
|
110
|
-
? await flows.submitStep(parsedSlug.data, stepPayload,
|
|
131
|
+
const res = stepInit
|
|
132
|
+
? await flows.submitStep(parsedSlug.data, stepPayload, stepInit)
|
|
111
133
|
: await flows.submitStep(parsedSlug.data, stepPayload);
|
|
112
134
|
if (!res.ok) {
|
|
113
135
|
if (res.status === 400) {
|
|
@@ -37,6 +37,11 @@ import {
|
|
|
37
37
|
type NavAction,
|
|
38
38
|
type NavActionFlow,
|
|
39
39
|
} from "@working-theory/ui/chrome";
|
|
40
|
+
import {
|
|
41
|
+
cookiePurpose,
|
|
42
|
+
resolveConsentStatement,
|
|
43
|
+
SITE_CONSENT_CONFIG_DEFAULTS,
|
|
44
|
+
} from "@working-theory/validation";
|
|
40
45
|
import {
|
|
41
46
|
buildHreflangAlternates,
|
|
42
47
|
getStealthRobots,
|
|
@@ -82,6 +87,7 @@ import {
|
|
|
82
87
|
} from "../../lib/topology.generated";
|
|
83
88
|
import { resolveBaseUrl } from "../../src/lib/routes";
|
|
84
89
|
|
|
90
|
+
import { ConsentBanner } from "./consent-banner";
|
|
85
91
|
import { readSiteContactLines } from "./contact/site-contact-lines";
|
|
86
92
|
|
|
87
93
|
/**
|
|
@@ -362,6 +368,15 @@ export default async function LocaleLayout({
|
|
|
362
368
|
// scaffold's messages file; the Banner below is UI-only — OSS ships no
|
|
363
369
|
// enforcement, the callbacks are the hook for @working-theory/consent.
|
|
364
370
|
const tConsent = await getTranslations({ locale, namespace: "consent" });
|
|
371
|
+
// #8438 C4/D12 — the banner's body is read FROM the registry, not a
|
|
372
|
+
// hard-coded string: the `analytics_cookies` purpose's statement, so the
|
|
373
|
+
// text the visitor reads and the text the ledger logs are one string by
|
|
374
|
+
// construction. No site carries its own registry row yet (C1), so this
|
|
375
|
+
// always reads the platform default — wiring a per-site row is a follow-up.
|
|
376
|
+
const consentCookiePurpose = cookiePurpose(SITE_CONSENT_CONFIG_DEFAULTS);
|
|
377
|
+
const consentBannerStatement = consentCookiePurpose
|
|
378
|
+
? resolveConsentStatement(consentCookiePurpose, locale).statement
|
|
379
|
+
: "";
|
|
365
380
|
|
|
366
381
|
// Right-side action cluster (#4984). Signed out → the auth pair: an
|
|
367
382
|
// outline-strong "Sign in" → /sign-in beside a primary "Sign up" → /sign-up
|
|
@@ -868,7 +883,7 @@ export default async function LocaleLayout({
|
|
|
868
883
|
* control (D2) instead of a hard server-side hide.
|
|
869
884
|
*/}
|
|
870
885
|
{isAccountRoute ? null : (
|
|
871
|
-
<
|
|
886
|
+
<ConsentBanner
|
|
872
887
|
variant="info"
|
|
873
888
|
placement="bottom"
|
|
874
889
|
consentCookieName={CONSENT_COOKIE_NAME}
|
|
@@ -876,7 +891,7 @@ export default async function LocaleLayout({
|
|
|
876
891
|
title={tConsent("title")}
|
|
877
892
|
message={
|
|
878
893
|
<>
|
|
879
|
-
{
|
|
894
|
+
{consentBannerStatement}{" "}
|
|
880
895
|
<Link
|
|
881
896
|
href={localeHref(locale, "/privacy")}
|
|
882
897
|
variant="unstyled"
|
|
@@ -891,6 +906,12 @@ export default async function LocaleLayout({
|
|
|
891
906
|
acceptLabel={tConsent("acceptLabel")}
|
|
892
907
|
secondaryAction={{ label: tConsent("rejectLabel") }}
|
|
893
908
|
rejectLabel={tConsent("rejectLabel")}
|
|
909
|
+
// #8438 D12 — each banner choice is logged against the
|
|
910
|
+
// visitor id through the one write path, `consent.actions.ts`,
|
|
911
|
+
// which `ConsentBanner` calls itself (never a function passed
|
|
912
|
+
// across the RSC boundary); the browser cookie
|
|
913
|
+
// (`CONSENT_COOKIE_NAME`) stays the operative switch the
|
|
914
|
+
// island itself still writes (C3).
|
|
894
915
|
locale={locale}
|
|
895
916
|
// #7742 D5 — the minimal chrome's consent bar: the page ground,
|
|
896
917
|
// a hairline, no shadow.
|
|
@@ -22,7 +22,9 @@
|
|
|
22
22
|
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
23
23
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
24
24
|
import { subscribeNewsletterInputSchema } from "@working-theory/validation";
|
|
25
|
-
import { headers } from "next/headers";
|
|
25
|
+
import { cookies, headers } from "next/headers";
|
|
26
|
+
|
|
27
|
+
import { consentVisitorHeader, readConsentVisitor } from "../../../lib/consent-visitor";
|
|
26
28
|
|
|
27
29
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
28
30
|
|
|
@@ -58,6 +60,15 @@ export async function subscribeNewsletterAction(
|
|
|
58
60
|
// No request scope to read — subscribe without forwarding.
|
|
59
61
|
}
|
|
60
62
|
|
|
63
|
+
// #8438 D12 — the banner's visitor id, so a later subscribe links its
|
|
64
|
+
// cookie choices to the client record. Absent or invalid → no header.
|
|
65
|
+
try {
|
|
66
|
+
const visitorId = readConsentVisitor(await cookies());
|
|
67
|
+
if (visitorId) init = { headers: { ...(init?.headers ?? {}), ...consentVisitorHeader(visitorId) } };
|
|
68
|
+
} catch {
|
|
69
|
+
// No request scope to read — subscribe with no visitor-link header.
|
|
70
|
+
}
|
|
71
|
+
|
|
61
72
|
try {
|
|
62
73
|
const res = init
|
|
63
74
|
? await leads.subscribe(body, init)
|