create-ailk 0.19.0 → 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/add-module.js +7 -2
- package/dist/cli.js +35 -0
- package/dist/copy.d.ts +41 -0
- package/dist/copy.js +87 -21
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- 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/lib/scaffold-render-sweep.d.ts +23 -0
- package/dist/lib/scaffold-render-sweep.js +52 -0
- package/dist/module-architecture.js +64 -5
- package/dist/programmatic.d.ts +7 -1
- package/dist/programmatic.js +40 -3
- package/dist/site-roster.js +21 -4
- package/dist/spoke-checkout.d.ts +7 -0
- package/dist/spoke-checkout.js +8 -1
- package/dist/surfaces.d.ts +14 -0
- package/dist/surfaces.js +98 -3
- package/dist/template-record.d.ts +59 -0
- package/dist/template-record.js +183 -0
- package/dist/topology.js +29 -1
- 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/.env.example +17 -1
- package/templates/apps/api/CLAUDE.md +18 -8
- package/templates/apps/api/package.json +2 -1
- package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
- package/templates/apps/api/src/__tests__/setup.ts +7 -0
- package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -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/__mocks__/prisma.ts +8 -0
- package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
- package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
- package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
- package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
- package/templates/apps/api/src/lib/consent-store.ts +335 -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/lib/stripe.ts +97 -5
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
- 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 +72 -14
- package/templates/apps/api/src/middleware/auth.ts +16 -4
- package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
- package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
- package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
- package/templates/apps/api/src/routes/checkout/session.ts +3 -3
- package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
- package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
- package/templates/apps/api/src/routes/consent/index.ts +18 -0
- package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
- package/templates/apps/api/src/routes/leads/index.ts +15 -0
- package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
- package/templates/apps/api/src/routes/webhooks/README.md +33 -5
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
- package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -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 +201 -6
- package/templates/apps/api/src/server.ts +125 -18
- package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
- package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
- package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
- package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
- package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
- package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
- package/templates/apps/api/src/services/audit-log.ts +72 -10
- package/templates/apps/api/src/services/consent.ts +488 -3
- 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/lead-routing.ts +105 -12
- package/templates/apps/api/src/services/person.ts +501 -1
- package/templates/apps/api/src/services/project-listings.ts +7 -2
- package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
- package/templates/apps/api/src/services/request-confirm.ts +52 -0
- package/templates/apps/api/src/services/revalidation.ts +11 -1
- 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__/account-frame-sign-out.test.tsx +175 -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__/billing.test.tsx +17 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -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__/security.test.tsx +290 -20
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
- 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 +15 -9
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
- 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/security/page.tsx +89 -37
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
- 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/auth.ts +20 -1
- package/templates/apps/web/jest.config.cjs +3 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -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/auth-email.ts +45 -10
- package/templates/apps/web/lib/consent-visitor.ts +103 -0
- package/templates/apps/web/lib/relay.ts +20 -0
- package/templates/apps/web/lib/topology.generated.ts +9 -0
- package/templates/apps/web/messages/en.json +121 -18
- package/templates/apps/web/next.config.mjs +8 -2
- package/templates/database/CHANGELOG.md +55 -0
- package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
- package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
- package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
- package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
- package/templates/database/__tests__/person-contact.db.test.ts +563 -0
- package/templates/database/__tests__/person-notes.db.test.ts +249 -0
- package/templates/database/__tests__/person.db.test.ts +3 -1
- 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 +114 -22
- package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
- package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
- 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
|
@@ -19,21 +19,41 @@
|
|
|
19
19
|
* launch lands on `errors.email` (the social row sits inside the password form
|
|
20
20
|
* beside the email field). The SSO body has no fields, so the same message is
|
|
21
21
|
* rendered as an `Alert` above the card for `method="sso"`.
|
|
22
|
+
*
|
|
23
|
+
* #8438 D13 — the site's sign-up agreements. `AuthCard` has no slot to render
|
|
24
|
+
* inside and no external "disabled" hook (packages/ui is not this unit's to
|
|
25
|
+
* change), so the card composes its own `ConsentGroup` below `AuthCard` and
|
|
26
|
+
* GATES every entry point from the outside: the password submit and every
|
|
27
|
+
* provider's click both run through this component's own closures, which do
|
|
28
|
+
* nothing until every required agreement is ticked. A wrapping div's
|
|
29
|
+
* `aria-disabled` + `pointer-events-none` styling carries the same state
|
|
30
|
+
* visually for a mouse/touch pointer in a real browser.
|
|
22
31
|
*/
|
|
23
32
|
|
|
24
33
|
import {
|
|
25
34
|
AuthCard,
|
|
35
|
+
ConsentGroup,
|
|
26
36
|
type AuthCardLabels,
|
|
27
37
|
type AuthSubmitData,
|
|
28
38
|
} from "@working-theory/ui/blocks";
|
|
29
39
|
import { Alert, AlertDescription } from "@working-theory/ui/primitives";
|
|
30
|
-
import type
|
|
40
|
+
import { useState, type MouseEvent, type ReactElement } from "react";
|
|
31
41
|
|
|
32
42
|
import { authClient, useAuthCardFlow } from "../use-auth-card-flow";
|
|
33
43
|
|
|
44
|
+
import { rememberSignUpAgreementsAction } from "./sign-up.actions";
|
|
45
|
+
|
|
34
46
|
/** The OAuth providers this page can launch — a subset of AuthCard's. */
|
|
35
47
|
export type SignUpProvider = "google" | "github";
|
|
36
48
|
|
|
49
|
+
/** One site agreement asked at sign-up (#8438 D1's `signUp` purposes). */
|
|
50
|
+
export interface SignUpAgreement {
|
|
51
|
+
id: string;
|
|
52
|
+
required: boolean;
|
|
53
|
+
/** Resolved to the active locale, link tokens included (D1). */
|
|
54
|
+
statement: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
37
57
|
export interface SignUpCardProps {
|
|
38
58
|
/** Active OAuth providers, exactly as auth-env reports them. */
|
|
39
59
|
providers: SignUpProvider[];
|
|
@@ -49,8 +69,16 @@ export interface SignUpCardProps {
|
|
|
49
69
|
notConfiguredSubhead: string;
|
|
50
70
|
/** Translated error copy. */
|
|
51
71
|
errorLabels: { signUpFailed: string; providerFailed: string };
|
|
72
|
+
/** The site's `signUp` agreements — always at least `account_terms`. */
|
|
73
|
+
agreements: SignUpAgreement[];
|
|
74
|
+
/** Policy key → href, for the statements' inline links (D1/D3). */
|
|
75
|
+
agreementPolicies: Record<string, string>;
|
|
76
|
+
/** The consent fieldset's legend (`auth.signUp.agreementsLegend`). */
|
|
77
|
+
agreementsLegend: string;
|
|
52
78
|
}
|
|
53
79
|
|
|
80
|
+
const AGREEMENTS_FIELD_NAME = "agreements";
|
|
81
|
+
|
|
54
82
|
export function SignUpCard({
|
|
55
83
|
providers,
|
|
56
84
|
method,
|
|
@@ -59,6 +87,9 @@ export function SignUpCard({
|
|
|
59
87
|
labels,
|
|
60
88
|
notConfiguredSubhead,
|
|
61
89
|
errorLabels,
|
|
90
|
+
agreements,
|
|
91
|
+
agreementPolicies,
|
|
92
|
+
agreementsLegend,
|
|
62
93
|
}: SignUpCardProps): ReactElement {
|
|
63
94
|
const { errors, providerError, launchProvider, submitPassword } =
|
|
64
95
|
useAuthCardFlow(next, {
|
|
@@ -66,25 +97,62 @@ export function SignUpCard({
|
|
|
66
97
|
passwordFailed: errorLabels.signUpFailed,
|
|
67
98
|
});
|
|
68
99
|
|
|
100
|
+
const requiredAgreementIds = agreements.filter((a) => a.required).map((a) => a.id);
|
|
101
|
+
const [ticked, setTicked] = useState<ReadonlySet<string>>(new Set());
|
|
102
|
+
const allAgreed = requiredAgreementIds.every((id) => ticked.has(id));
|
|
103
|
+
|
|
104
|
+
// `ConsentGroup`'s checkboxes are uncontrolled, by design (#5275), and
|
|
105
|
+
// always start unticked (no `defaults` are ever passed here) — so a click
|
|
106
|
+
// on one is always a plain toggle of the LOCAL record below; this never
|
|
107
|
+
// reads the Radix root's own post-click DOM state, which a same-tick
|
|
108
|
+
// ancestor listener could still see as stale.
|
|
109
|
+
function handleAgreementsClick(event: MouseEvent<HTMLDivElement>): void {
|
|
110
|
+
const target = (event.target as HTMLElement).closest<HTMLElement>('[role="checkbox"]');
|
|
111
|
+
if (!target?.id) return;
|
|
112
|
+
const purposeId = target.id.slice(`${AGREEMENTS_FIELD_NAME}.`.length);
|
|
113
|
+
setTicked((previous) => {
|
|
114
|
+
const updated = new Set(previous);
|
|
115
|
+
if (updated.has(purposeId)) updated.delete(purposeId);
|
|
116
|
+
else updated.add(purposeId);
|
|
117
|
+
return updated;
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
69
121
|
const notConfigured = method === "sso" && providers.length === 0;
|
|
70
122
|
|
|
71
123
|
const providerButtons = providers.map((provider) => ({
|
|
72
124
|
provider,
|
|
73
|
-
|
|
125
|
+
// Gated: a provider launch that would create the account is refused
|
|
126
|
+
// until every required agreement is ticked, exactly as the password
|
|
127
|
+
// submit below. No remember-cookie here — Better Auth's own OAuth
|
|
128
|
+
// callback carries no form data this component could read an email
|
|
129
|
+
// from, so the account home's explicit "Otherwise" card asks instead.
|
|
130
|
+
onClick: () => {
|
|
131
|
+
if (!allAgreed) return;
|
|
132
|
+
void launchProvider(provider);
|
|
133
|
+
},
|
|
74
134
|
}));
|
|
75
135
|
|
|
76
136
|
const handleSubmit = (data: AuthSubmitData): void => {
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
137
|
+
if (!allAgreed) return;
|
|
138
|
+
void (async () => {
|
|
139
|
+
try {
|
|
140
|
+
await rememberSignUpAgreementsAction(data.email, requiredAgreementIds);
|
|
141
|
+
} catch {
|
|
142
|
+
// Convenience only — see sign-up.actions.ts's file header.
|
|
143
|
+
}
|
|
144
|
+
// Better Auth requires a `name` on email sign-up; this card collects
|
|
145
|
+
// none, so the address doubles as the display name until the person
|
|
146
|
+
// sets one.
|
|
147
|
+
await submitPassword(
|
|
148
|
+
authClient.signUp.email({
|
|
149
|
+
email: data.email,
|
|
150
|
+
password: data.password ?? "",
|
|
151
|
+
name: data.email,
|
|
152
|
+
callbackURL: next,
|
|
153
|
+
}),
|
|
154
|
+
);
|
|
155
|
+
})();
|
|
88
156
|
};
|
|
89
157
|
|
|
90
158
|
return (
|
|
@@ -94,16 +162,31 @@ export function SignUpCard({
|
|
|
94
162
|
<AlertDescription>{providerError}</AlertDescription>
|
|
95
163
|
</Alert>
|
|
96
164
|
) : null}
|
|
97
|
-
<
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
165
|
+
<div
|
|
166
|
+
aria-disabled={!allAgreed}
|
|
167
|
+
className={allAgreed ? undefined : "pointer-events-none opacity-60"}
|
|
168
|
+
data-testid="sign-up-card-gate"
|
|
169
|
+
>
|
|
170
|
+
<AuthCard
|
|
171
|
+
intent="sign-up"
|
|
172
|
+
method={method}
|
|
173
|
+
signInHref={signInHref}
|
|
174
|
+
{...(notConfigured ? { subhead: notConfiguredSubhead } : {})}
|
|
175
|
+
providers={providerButtons}
|
|
176
|
+
onSubmit={handleSubmit}
|
|
177
|
+
errors={errors}
|
|
178
|
+
labels={labels}
|
|
179
|
+
/>
|
|
180
|
+
</div>
|
|
181
|
+
{/* eslint-disable-next-line -- delegated-click capture over ConsentGroup's own real `role="checkbox"` descendants (Radix), which are themselves already keyboard-operable and dispatch a native click on Enter/Space; this wrapper adds no interactive semantics of its own and is never itself focused. (Bare directive, not named rules: `jsx-a11y/*` is registered in this monorepo's own lint but not in a scaffolded site's trimmed eslint config — see agreements-prompt.tsx's identical note on `react-hooks/exhaustive-deps`.) */}
|
|
182
|
+
<div onClick={handleAgreementsClick} data-testid="sign-up-agreements">
|
|
183
|
+
<ConsentGroup
|
|
184
|
+
name={AGREEMENTS_FIELD_NAME}
|
|
185
|
+
legend={agreementsLegend}
|
|
186
|
+
purposes={agreements}
|
|
187
|
+
policies={agreementPolicies}
|
|
188
|
+
/>
|
|
189
|
+
</div>
|
|
107
190
|
</div>
|
|
108
191
|
);
|
|
109
192
|
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* sign-up.actions.ts — mints the sign-up card's carried-agreement cookie
|
|
5
|
+
* (#8438 D13, spec
|
|
6
|
+
* docs/specs/8438-consent-registry-and-ledger-step-spec.md C6).
|
|
7
|
+
*
|
|
8
|
+
* `apps/web` never touches the database (the ledger writer is `apps/api`),
|
|
9
|
+
* and Better Auth creates the user from THIS app before any Person exists to
|
|
10
|
+
* record a grant against. So the card remembers which agreements the person
|
|
11
|
+
* ticked, in a short-lived, signed, email-bound cookie — the account home's
|
|
12
|
+
* first signed-in visit (`(authed)/account/actions.ts`'s
|
|
13
|
+
* `acceptCarriedAgreementsAction`) consumes it, one write per agreement,
|
|
14
|
+
* then deletes it either way (single-use).
|
|
15
|
+
*
|
|
16
|
+
* The CONSUME side lives in the account area, not here, deliberately:
|
|
17
|
+
* `(auth)/**` is one of the sign-in seeds a sign-in-less spoke drops
|
|
18
|
+
* (`SPOKE_REMOVAL_SEEDS`, `packages/create-ailk/src/topology.ts` D4), so a
|
|
19
|
+
* file OUTSIDE `(auth)` (the account home's `agreements-prompt.tsx`) must
|
|
20
|
+
* never import FROM here — doing so once failed the scaffolder's own
|
|
21
|
+
* dangling-import check (`spokeExclusions`) for exactly this reason. This
|
|
22
|
+
* file is reached only from `sign-up-card.tsx`, its own directory sibling,
|
|
23
|
+
* so it carries no such obligation. The two files duplicate the small
|
|
24
|
+
* cookie codec (encode here, decode there) rather than share a module,
|
|
25
|
+
* precisely so each side's dependencies stay native to where it lives.
|
|
26
|
+
*
|
|
27
|
+
* Cookie value: `<id>@<version>,…|<issuedAtMs>|<sha256(lowercased email)>|
|
|
28
|
+
* <HMAC-SHA256 over the rest>`, keyed by `BETTER_AUTH_SECRET` — the same
|
|
29
|
+
* dev fallback `apps/web/auth.ts`'s own `resolveSecret()` publishes when it
|
|
30
|
+
* is unset; production refuses to mint without one. That secret is read
|
|
31
|
+
* directly HERE rather than importing `resolveSecret` from `auth.ts`: that
|
|
32
|
+
* module statically imports the `better-auth` ESM package, which every
|
|
33
|
+
* broad page-level test suite that renders `/sign-up` would then have to
|
|
34
|
+
* add to its `../../../../auth` mock. `__Host-` prefixed, httpOnly,
|
|
35
|
+
* `Secure`, one hour, `SameSite=Lax` — the `__Host-` prefix needs `Secure`,
|
|
36
|
+
* so development over plain `http://localhost` uses the unprefixed name
|
|
37
|
+
* instead (SEC-8438-5).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
import { createHash, createHmac } from "node:crypto";
|
|
41
|
+
|
|
42
|
+
import { consentPurposeById, SITE_CONSENT_CONFIG_DEFAULTS } from "@working-theory/validation";
|
|
43
|
+
import { cookies } from "next/headers";
|
|
44
|
+
|
|
45
|
+
const SECURE_COOKIE_NAME = "__Host-ailk-signup-agreements";
|
|
46
|
+
const DEV_COOKIE_NAME = "ailk-signup-agreements";
|
|
47
|
+
const MAX_AGE_SECONDS = 60 * 60;
|
|
48
|
+
|
|
49
|
+
/** The string `auth.ts`'s own `resolveSecret()` publishes — see file header. */
|
|
50
|
+
const DEV_FALLBACK_SECRET =
|
|
51
|
+
"ailk-dev-fallback-secret-published-in-this-repo-never-use-in-production";
|
|
52
|
+
|
|
53
|
+
/** Mirrors `auth.ts`'s `resolveSecret()` exactly, without importing it. */
|
|
54
|
+
function signUpAgreementsSecret(): string {
|
|
55
|
+
const configured = process.env.BETTER_AUTH_SECRET;
|
|
56
|
+
if (configured) return configured;
|
|
57
|
+
const isBuildPhase = process.env.NEXT_PHASE === "phase-production-build";
|
|
58
|
+
if (process.env.NODE_ENV === "production" && !isBuildPhase) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"BETTER_AUTH_SECRET is required in production: it signs the carried sign-up-agreement cookie.",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
return DEV_FALLBACK_SECRET;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function cookieName(): string {
|
|
67
|
+
return process.env.NODE_ENV === "production" ? SECURE_COOKIE_NAME : DEV_COOKIE_NAME;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function emailHash(email: string): string {
|
|
71
|
+
return createHash("sha256").update(email.trim().toLowerCase()).digest("hex");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function sign(payload: string): string {
|
|
75
|
+
return createHmac("sha256", signUpAgreementsSecret()).update(payload).digest("hex");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
interface CarriedPurpose {
|
|
79
|
+
id: string;
|
|
80
|
+
version: number;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function encodeCarriedAgreements(
|
|
84
|
+
purposes: CarriedPurpose[],
|
|
85
|
+
email: string,
|
|
86
|
+
issuedAtMs: number,
|
|
87
|
+
): string {
|
|
88
|
+
const purposesPart = purposes.map((p) => `${p.id}@${p.version}`).join(",");
|
|
89
|
+
const payload = `${purposesPart}|${issuedAtMs}|${emailHash(email)}`;
|
|
90
|
+
return `${payload}|${sign(payload)}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Mints the carried-agreement cookie from the ticked `signUp` agreements,
|
|
95
|
+
* read back from the registry for their CURRENT version — never trusting a
|
|
96
|
+
* version the client might assert. Purposes the registry no longer marks
|
|
97
|
+
* `signUp` are dropped. Never throws: a failed mint just means the account
|
|
98
|
+
* home's first visit asks explicitly instead (D13's "Otherwise" card).
|
|
99
|
+
*/
|
|
100
|
+
export async function rememberSignUpAgreementsAction(
|
|
101
|
+
email: string,
|
|
102
|
+
purposeIds: string[],
|
|
103
|
+
): Promise<void> {
|
|
104
|
+
try {
|
|
105
|
+
const config = SITE_CONSENT_CONFIG_DEFAULTS;
|
|
106
|
+
const purposes: CarriedPurpose[] = [];
|
|
107
|
+
for (const id of purposeIds) {
|
|
108
|
+
const purpose = consentPurposeById(config, id);
|
|
109
|
+
if (purpose?.signUp) purposes.push({ id: purpose.id, version: purpose.policyVersion });
|
|
110
|
+
}
|
|
111
|
+
if (purposes.length === 0) return;
|
|
112
|
+
|
|
113
|
+
const value = encodeCarriedAgreements(purposes, email, Date.now());
|
|
114
|
+
const cookieStore = await cookies();
|
|
115
|
+
cookieStore.set(cookieName(), value, {
|
|
116
|
+
httpOnly: true,
|
|
117
|
+
secure: process.env.NODE_ENV === "production",
|
|
118
|
+
sameSite: "lax",
|
|
119
|
+
path: "/",
|
|
120
|
+
maxAge: MAX_AGE_SECONDS,
|
|
121
|
+
});
|
|
122
|
+
} catch {
|
|
123
|
+
// Convenience only — see file header.
|
|
124
|
+
}
|
|
125
|
+
}
|
package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Log out in AccountFrame (#8465).
|
|
3
|
+
*
|
|
4
|
+
* A spoke that signs in through its hub holds its session in the sealed
|
|
5
|
+
* hub-session cookie, which only `POST /api/hub-session/sign-out` clears
|
|
6
|
+
* (and which also revokes the hub credential); Better Auth's `signOut()` hits
|
|
7
|
+
* a route that spoke does not serve. The server layout hands the seam's
|
|
8
|
+
* `SIGN_OUT_ENDPOINT` down as `signOutEndpoint`:
|
|
9
|
+
*
|
|
10
|
+
* - with an endpoint, Log out POSTs it (same-origin), never calls Better
|
|
11
|
+
* Auth, and lands on the sign-in page once the answer is 2xx;
|
|
12
|
+
* - a refused or failed sign-out (403 on an Origin mismatch, 5xx, a
|
|
13
|
+
* rejected fetch) stays on the page, shows the translated error, and
|
|
14
|
+
* navigates nowhere — the cookie is still valid (#8467 review addendum);
|
|
15
|
+
* - without an endpoint (a hub or a single site) it calls Better Auth and
|
|
16
|
+
* routes home, as before.
|
|
17
|
+
*/
|
|
18
|
+
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
|
19
|
+
import type { ReactNode } from "react";
|
|
20
|
+
|
|
21
|
+
const mockSignOut = jest.fn();
|
|
22
|
+
jest.mock("better-auth/react", () => ({
|
|
23
|
+
__esModule: true,
|
|
24
|
+
createAuthClient: () => ({
|
|
25
|
+
signOut: (...args: unknown[]) => mockSignOut(...args),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
const mockPush = jest.fn();
|
|
30
|
+
const mockRefresh = jest.fn();
|
|
31
|
+
jest.mock("next/navigation", () => ({
|
|
32
|
+
__esModule: true,
|
|
33
|
+
useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
|
|
34
|
+
usePathname: () => "/account",
|
|
35
|
+
useSearchParams: () => new URLSearchParams(""),
|
|
36
|
+
}));
|
|
37
|
+
|
|
38
|
+
jest.mock("@working-theory/templates/shells", () => ({
|
|
39
|
+
__esModule: true,
|
|
40
|
+
SignedInShell: ({
|
|
41
|
+
onSignOut,
|
|
42
|
+
children,
|
|
43
|
+
}: {
|
|
44
|
+
onSignOut: () => void;
|
|
45
|
+
children: ReactNode;
|
|
46
|
+
}) => (
|
|
47
|
+
<div>
|
|
48
|
+
<button type="button" data-testid="sign-out" onClick={onSignOut} />
|
|
49
|
+
{children}
|
|
50
|
+
</div>
|
|
51
|
+
),
|
|
52
|
+
}));
|
|
53
|
+
|
|
54
|
+
jest.mock("@working-theory/templates/account", () => ({
|
|
55
|
+
__esModule: true,
|
|
56
|
+
ForYouPanel: () => null,
|
|
57
|
+
PhoneTabProvider: ({ children }: { children: ReactNode }) => children,
|
|
58
|
+
useForYouDismissed: () => [new Set<string>(), jest.fn()],
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
import { AccountFrame, type AccountFrameProps } from "../account-frame";
|
|
62
|
+
|
|
63
|
+
const ENDPOINT = "/api/hub-session/sign-out";
|
|
64
|
+
const FAILED = "We couldn't log you out. You're still signed in; try again.";
|
|
65
|
+
const mockFetch = jest.fn();
|
|
66
|
+
|
|
67
|
+
function renderFrame(extra: Partial<AccountFrameProps> = {}): void {
|
|
68
|
+
render(
|
|
69
|
+
<AccountFrame
|
|
70
|
+
locale="en"
|
|
71
|
+
user={{ name: "Ada", email: "ada@example.com" }}
|
|
72
|
+
logo={null}
|
|
73
|
+
helpItems={[]}
|
|
74
|
+
// eslint-disable-next-line no-restricted-syntax -- partial fixture: the suite exercises only the sign-out form, so the full forYou/labels shapes are not built
|
|
75
|
+
forYou={{ items: [] } as unknown as AccountFrameProps["forYou"]}
|
|
76
|
+
labels={
|
|
77
|
+
// eslint-disable-next-line no-restricted-syntax -- partial fixture (see forYou above)
|
|
78
|
+
{
|
|
79
|
+
home: "Home",
|
|
80
|
+
signOutFailed: FAILED,
|
|
81
|
+
} as unknown as AccountFrameProps["labels"]
|
|
82
|
+
}
|
|
83
|
+
{...extra}
|
|
84
|
+
>
|
|
85
|
+
<p>page</p>
|
|
86
|
+
</AccountFrame>,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
jest.clearAllMocks();
|
|
92
|
+
globalThis.fetch = mockFetch.mockResolvedValue({ ok: true, status: 204 });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
describe("AccountFrame Log out — a spoke that signs in through its hub", () => {
|
|
96
|
+
it("POSTs the hub sign-out endpoint, never Better Auth, then lands on the sign-in page and refreshes", async () => {
|
|
97
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
98
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
99
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
|
|
100
|
+
expect(mockFetch).toHaveBeenCalledWith(ENDPOINT, {
|
|
101
|
+
method: "POST",
|
|
102
|
+
credentials: "same-origin",
|
|
103
|
+
});
|
|
104
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
105
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
106
|
+
expect(
|
|
107
|
+
screen.queryByTestId("account-sign-out-error"),
|
|
108
|
+
).not.toBeInTheDocument();
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("falls back to the site home when no sign-in href is given", async () => {
|
|
112
|
+
renderFrame({ signOutEndpoint: ENDPOINT });
|
|
113
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
114
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it.each([
|
|
118
|
+
["a 403 (Origin mismatch)", { ok: false, status: 403 }],
|
|
119
|
+
["a 500", { ok: false, status: 500 }],
|
|
120
|
+
["a 404", { ok: false, status: 404 }],
|
|
121
|
+
])(
|
|
122
|
+
"%s stays on the page with the error and routes nowhere",
|
|
123
|
+
async (_label, response) => {
|
|
124
|
+
mockFetch.mockResolvedValue(response);
|
|
125
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
126
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
127
|
+
expect(
|
|
128
|
+
await screen.findByTestId("account-sign-out-error"),
|
|
129
|
+
).toHaveTextContent(FAILED);
|
|
130
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
131
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
132
|
+
expect(mockSignOut).not.toHaveBeenCalled();
|
|
133
|
+
},
|
|
134
|
+
);
|
|
135
|
+
|
|
136
|
+
it("a rejected request stays on the page with the error and routes nowhere", async () => {
|
|
137
|
+
mockFetch.mockRejectedValue(new TypeError("network down"));
|
|
138
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
139
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
140
|
+
expect(
|
|
141
|
+
await screen.findByTestId("account-sign-out-error"),
|
|
142
|
+
).toHaveTextContent(FAILED);
|
|
143
|
+
expect(mockPush).not.toHaveBeenCalled();
|
|
144
|
+
expect(mockRefresh).not.toHaveBeenCalled();
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it("a retry that succeeds clears the error and lands on the sign-in page", async () => {
|
|
148
|
+
mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
|
|
149
|
+
renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
|
|
150
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
151
|
+
await screen.findByTestId("account-sign-out-error");
|
|
152
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
153
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
|
|
154
|
+
expect(
|
|
155
|
+
screen.queryByTestId("account-sign-out-error"),
|
|
156
|
+
).not.toBeInTheDocument();
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("AccountFrame Log out — a hub or a single site", () => {
|
|
161
|
+
it.each([
|
|
162
|
+
["absent", {}],
|
|
163
|
+
["null", { signOutEndpoint: null }],
|
|
164
|
+
])(
|
|
165
|
+
"with the endpoint %s calls Better Auth, then routes home and refreshes",
|
|
166
|
+
async (_label, extra) => {
|
|
167
|
+
renderFrame(extra);
|
|
168
|
+
fireEvent.click(screen.getByTestId("sign-out"));
|
|
169
|
+
await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
|
|
170
|
+
expect(mockSignOut).toHaveBeenCalledTimes(1);
|
|
171
|
+
expect(mockFetch).not.toHaveBeenCalled();
|
|
172
|
+
expect(mockRefresh).toHaveBeenCalledTimes(1);
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
});
|