create-ailk 0.11.2 → 0.11.4
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/component-catalog.json +12 -1
- package/component-catalog.md +6 -5
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +29 -0
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/transactional-email.test.ts +105 -1
- package/templates/apps/api/src/routes/auth/transactional-email.ts +37 -9
- package/templates/apps/api/src/server.ts +28 -0
- package/templates/apps/api/src/services/__tests__/lead-accept-user.test.ts +251 -0
- package/templates/apps/api/src/services/lead-accept-user.ts +126 -0
- package/templates/apps/api/src/vercel-handler.ts +58 -12
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +81 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +4 -0
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +3 -1
- package/templates/apps/web/.env.example +4 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +2 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +14 -9
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +5 -4
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +2 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +69 -2
- package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +156 -0
- package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +44 -26
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +104 -4
- package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +57 -0
- package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +306 -28
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +143 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +372 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +258 -225
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +397 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +203 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +183 -0
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +169 -0
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +3 -1
- package/templates/apps/web/app/[locale]/(authed)/account/error-row.tsx +35 -0
- package/templates/apps/web/app/[locale]/(authed)/account/for-you-labels.ts +36 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/[kind]/[id]/page.tsx +96 -0
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +89 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +140 -293
- package/templates/apps/web/app/[locale]/(authed)/account/retry-button.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +68 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +55 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +118 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +298 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/theme-modes.ts +32 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/page.tsx +60 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support/support-form.tsx +98 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +98 -0
- package/templates/apps/web/app/[locale]/(authed)/layout.tsx +77 -3
- package/templates/apps/web/app/[locale]/(authed)/sign-in-gate.tsx +52 -9
- package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +63 -2
- package/templates/apps/web/app/[locale]/contact/contact.actions.ts +22 -2
- package/templates/apps/web/app/[locale]/layout.tsx +41 -28
- package/templates/apps/web/app/[locale]/newsletter/__tests__/newsletter.actions.test.ts +66 -2
- package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +22 -6
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +176 -0
- package/templates/apps/web/app/openapi.json/route.ts +127 -0
- package/templates/apps/web/auth.ts +29 -5
- package/templates/apps/web/jest.config.cjs +5 -0
- package/templates/apps/web/lib/__tests__/account-config.test.ts +285 -0
- package/templates/apps/web/lib/__tests__/auth-email.test.ts +156 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +41 -2
- package/templates/apps/web/lib/account-config.ts +219 -11
- package/templates/apps/web/lib/auth-email.ts +104 -2
- package/templates/apps/web/lib/auth-env.ts +37 -5
- package/templates/apps/web/lib/auth-routes.ts +33 -4
- package/templates/apps/web/messages/en.json +148 -21
- package/templates/database/CHANGELOG.md +14 -0
- package/templates/database/package.json +1 -1
- package/versions.json +33 -33
- package/templates/apps/web/app/[locale]/(authed)/account/sign-out-button.tsx +0 -39
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use server";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* account/support-actions.ts — the account area's Support request action.
|
|
5
|
+
*
|
|
6
|
+
* `submitSupportRequest` (#7430, spec 7423 D11) — files a Support request
|
|
7
|
+
* (and, from User settings › Privacy, an account-deletion request) as a
|
|
8
|
+
* contact lead: `POST /v1/leads/submit` with `source: "web"`, the session's
|
|
9
|
+
* email and the payload `{ kind, subject, message }`. No `siteId` — the
|
|
10
|
+
* route resolves the tenant server-side and ignores a posted one (#6203).
|
|
11
|
+
* The request lands in the admin Inbox as a message. Not a `*Action`-suffixed
|
|
12
|
+
* export in a `*.actions.ts` file: lead capture already has its MCP twin
|
|
13
|
+
* (`captureLeadAction` ↔ `ailkCaptureLead`), so this is not a second
|
|
14
|
+
* Server-Action/MCP-tool pair (apps/mcp's parity test).
|
|
15
|
+
*
|
|
16
|
+
* Its own module, apart from `actions.ts`, so `create-ailk sync-routes` can
|
|
17
|
+
* bring it along with the Support and User settings routes as a new file: a
|
|
18
|
+
* scaffold's existing `actions.ts` is never overwritten.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
22
|
+
import { headers } from "next/headers";
|
|
23
|
+
|
|
24
|
+
import { auth } from "../../../../auth";
|
|
25
|
+
|
|
26
|
+
/** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
|
|
27
|
+
function errorName(err: unknown): string {
|
|
28
|
+
return err instanceof Error ? err.name : "unknown";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** What the request forms render after a submit. */
|
|
32
|
+
export type SupportRequestState =
|
|
33
|
+
| { kind: "idle" }
|
|
34
|
+
| { kind: "success" }
|
|
35
|
+
| { kind: "error"; reason: "required" | "failed" | "unavailable" };
|
|
36
|
+
|
|
37
|
+
const MAX_SUBJECT = 200;
|
|
38
|
+
const MAX_MESSAGE = 5000;
|
|
39
|
+
|
|
40
|
+
function field(formData: FormData, name: string): string {
|
|
41
|
+
const value = formData.get(name);
|
|
42
|
+
return typeof value === "string" ? value.trim() : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A 503 whose body names `lead_capture_not_configured` (contact.actions.ts's reading). */
|
|
46
|
+
function isNotConfigured(res: { status: number; body: unknown }): boolean {
|
|
47
|
+
return (
|
|
48
|
+
res.status === 503 &&
|
|
49
|
+
typeof res.body === "object" &&
|
|
50
|
+
res.body !== null &&
|
|
51
|
+
(res.body as { error?: unknown }).error === "lead_capture_not_configured"
|
|
52
|
+
);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Files the signed-in member's Support (or deletion) request. The email is
|
|
57
|
+
* the session's own — never a form field — so a request always names the
|
|
58
|
+
* person who sent it.
|
|
59
|
+
*/
|
|
60
|
+
export async function submitSupportRequest(
|
|
61
|
+
_prev: SupportRequestState,
|
|
62
|
+
formData: FormData,
|
|
63
|
+
): Promise<SupportRequestState> {
|
|
64
|
+
const kind = field(formData, "kind") === "deletion" ? "deletion" : "support";
|
|
65
|
+
const subject = field(formData, "subject");
|
|
66
|
+
const message = field(formData, "message");
|
|
67
|
+
if (!subject || !message || subject.length > MAX_SUBJECT || message.length > MAX_MESSAGE) {
|
|
68
|
+
return { kind: "error", reason: "required" };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
const requestHeaders = await headers();
|
|
73
|
+
const session = await auth.api.getSession({ headers: requestHeaders });
|
|
74
|
+
if (!session) return { kind: "error", reason: "failed" };
|
|
75
|
+
|
|
76
|
+
const body = {
|
|
77
|
+
source: "web",
|
|
78
|
+
email: session.user.email,
|
|
79
|
+
payload: { kind, subject, message },
|
|
80
|
+
};
|
|
81
|
+
// #7394 — a spoke counts the request against the visitor's own budget.
|
|
82
|
+
const forwarded = visitorForwardHeaders(
|
|
83
|
+
requestHeaders,
|
|
84
|
+
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
85
|
+
);
|
|
86
|
+
const res = forwarded
|
|
87
|
+
? await leads.submit(body, { headers: forwarded })
|
|
88
|
+
: await leads.submit(body);
|
|
89
|
+
if (!res.ok) {
|
|
90
|
+
console.error(`[submitSupportRequest] status=${res.status}`);
|
|
91
|
+
return { kind: "error", reason: isNotConfigured(res) ? "unavailable" : "failed" };
|
|
92
|
+
}
|
|
93
|
+
return { kind: "success" };
|
|
94
|
+
} catch (err) {
|
|
95
|
+
console.error(`[submitSupportRequest] errName=${errorName(err)}`);
|
|
96
|
+
return { kind: "error", reason: "failed" };
|
|
97
|
+
}
|
|
98
|
+
}
|
|
@@ -10,17 +10,46 @@
|
|
|
10
10
|
* surface — #4984). This guard reads no public-route auth-requirement flag
|
|
11
11
|
* (spec Clarifications) — that flag governs whether PUBLIC marketing routes
|
|
12
12
|
* redirect; tenant signup data is never public, so this gate always applies.
|
|
13
|
+
*
|
|
14
|
+
* The gate's providers are resolved HERE, on the server, and passed down as a
|
|
15
|
+
* prop (#7419): `SignInGate` is a client component, and the provider env
|
|
16
|
+
* `getActiveProviderIds()` reads is server-only — read in the browser it is
|
|
17
|
+
* always empty, which made every gated page claim sign-in was not configured.
|
|
18
|
+
*
|
|
19
|
+
* When the email sign-in link is on (#7435 § D9 — the same
|
|
20
|
+
* `getActiveProviderIds()` predicate `auth.ts` registers the plugin on), the
|
|
21
|
+
* layout resolves `/sign-in`'s card copy here too and passes it as the gate's
|
|
22
|
+
* `emailLink` prop; otherwise it passes nothing and the gate renders as before.
|
|
23
|
+
*
|
|
24
|
+
* On an account path (#7430, `isAccountPath` on the forwarded `x-pathname`)
|
|
25
|
+
* the visitor is a member, not an operator: the gate reads the member-facing
|
|
26
|
+
* `account.gate` copy instead of `waitlistDashboard.gate` (which asks for
|
|
27
|
+
* provider env vars "to enable operator access"). When no sign-in method is
|
|
28
|
+
* on, the layout renders that member gate itself so its headline is a real
|
|
29
|
+
* `<h1>` — `SignInGate`'s not-configured branch renders `EmptyState`, whose
|
|
30
|
+
* headline is a `<p>` — and its one action leads back to the site rather than
|
|
31
|
+
* to a sign-in page that has nothing to offer. An absent `x-pathname` keeps
|
|
32
|
+
* the operator copy, the same fail-safe direction as the locale layout's
|
|
33
|
+
* account branch.
|
|
13
34
|
*/
|
|
14
35
|
|
|
15
36
|
import { localeHref } from "@working-theory/ui";
|
|
37
|
+
import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
|
|
38
|
+
import type { AuthPanelProvider } from "@working-theory/ui/sections";
|
|
39
|
+
import { readLocales } from "@working-theory/web-i18n";
|
|
16
40
|
import { headers } from "next/headers";
|
|
17
41
|
import { getTranslations } from "next-intl/server";
|
|
18
42
|
import type { ReactElement, ReactNode } from "react";
|
|
19
43
|
|
|
44
|
+
import { resolveSignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
20
45
|
import { auth } from "../../../auth";
|
|
46
|
+
import { getActiveProviderIds } from "../../../lib/auth-env";
|
|
47
|
+
import { isAccountPath } from "../../../lib/auth-routes";
|
|
21
48
|
|
|
22
49
|
import { SignInGate } from "./sign-in-gate";
|
|
23
50
|
|
|
51
|
+
const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
|
|
52
|
+
|
|
24
53
|
interface Props {
|
|
25
54
|
children: ReactNode;
|
|
26
55
|
params: Promise<{ locale: string }>;
|
|
@@ -31,13 +60,56 @@ export default async function AuthedLayout({
|
|
|
31
60
|
params,
|
|
32
61
|
}: Props): Promise<ReactElement> {
|
|
33
62
|
const { locale } = await params;
|
|
34
|
-
const
|
|
63
|
+
const requestHeaders = await headers();
|
|
64
|
+
const session = await auth.api.getSession({ headers: requestHeaders });
|
|
35
65
|
|
|
36
66
|
if (!session) {
|
|
67
|
+
// No `x-pathname`, no locale read: a render outside the middleware keeps
|
|
68
|
+
// the operator gate without touching the locale config.
|
|
69
|
+
const pathname = requestHeaders.get("x-pathname");
|
|
70
|
+
const isAccount =
|
|
71
|
+
pathname !== null && isAccountPath(pathname, readLocales());
|
|
37
72
|
const t = await getTranslations({
|
|
38
73
|
locale,
|
|
39
|
-
namespace: "waitlistDashboard.gate",
|
|
74
|
+
namespace: isAccount ? "account.gate" : "waitlistDashboard.gate",
|
|
40
75
|
});
|
|
76
|
+
const active = getActiveProviderIds();
|
|
77
|
+
const emailLink = active.includes("magic-link")
|
|
78
|
+
? { text: await resolveSignInCardText(locale) }
|
|
79
|
+
: undefined;
|
|
80
|
+
const providers = active.filter((id): id is AuthPanelProvider =>
|
|
81
|
+
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
82
|
+
);
|
|
83
|
+
const signInPath = localeHref(locale, "/sign-in");
|
|
84
|
+
|
|
85
|
+
if (isAccount && providers.length === 0 && !emailLink) {
|
|
86
|
+
return (
|
|
87
|
+
<div
|
|
88
|
+
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
|
|
89
|
+
data-testid="account-sign-in-gate"
|
|
90
|
+
>
|
|
91
|
+
<Heading level={1} visual={4}>
|
|
92
|
+
{t("emptyHeadline")}
|
|
93
|
+
</Heading>
|
|
94
|
+
<Text variant="body-sm" tone="muted" className="max-w-xs">
|
|
95
|
+
{t("emptyDescription")}
|
|
96
|
+
</Text>
|
|
97
|
+
{/* With no sign-in method on, /sign-in has nothing for a member
|
|
98
|
+
either, so the one action goes back to the site. Outline
|
|
99
|
+
action, 44px on a phone and 36px from md up. */}
|
|
100
|
+
<Button asChild variant="secondary" className="max-md:min-h-11">
|
|
101
|
+
<Link
|
|
102
|
+
href={localeHref(locale, "/")}
|
|
103
|
+
variant="bare"
|
|
104
|
+
data-testid="account-sign-in-gate-home"
|
|
105
|
+
>
|
|
106
|
+
{t("homeLink")}
|
|
107
|
+
</Link>
|
|
108
|
+
</Button>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
|
|
41
113
|
return (
|
|
42
114
|
<SignInGate
|
|
43
115
|
labels={{
|
|
@@ -45,7 +117,9 @@ export default async function AuthedLayout({
|
|
|
45
117
|
emptyDescription: t("emptyDescription"),
|
|
46
118
|
signInLinkLabel: t("signInLink"),
|
|
47
119
|
}}
|
|
48
|
-
signInPath={
|
|
120
|
+
signInPath={signInPath}
|
|
121
|
+
providers={providers}
|
|
122
|
+
{...(emailLink ? { emailLink } : {})}
|
|
49
123
|
/>
|
|
50
124
|
);
|
|
51
125
|
}
|
|
@@ -9,11 +9,22 @@
|
|
|
9
9
|
* (#4984) carrying `?next=<pathname>`, so a visitor who prefers the full
|
|
10
10
|
* sign-in surface lands back on the exact page they requested.
|
|
11
11
|
*
|
|
12
|
+
* `providers` arrives as a prop, resolved by the `(authed)` layout (an RSC)
|
|
13
|
+
* from the server's provider env (#7419). This module must never read that
|
|
14
|
+
* env itself: it is bundled for the browser, where `GOOGLE_CLIENT_ID` and the
|
|
15
|
+
* like are always unset, so a client-side read always came back empty.
|
|
16
|
+
*
|
|
12
17
|
* Zero active providers → an `EmptyState` naming the missing configuration
|
|
13
18
|
* (the `AuthPanel` UI-contract forbids fabricating a session — spec
|
|
14
19
|
* Clarifications), still followed by the sign-in link. `callbackURL` is the
|
|
15
20
|
* current pathname (via `usePathname()`) so a successful social sign-in
|
|
16
21
|
* lands back on the exact dashboard page the visitor requested.
|
|
22
|
+
*
|
|
23
|
+
* Email sign-in link (#7435 § D9): when the layout passes `emailLink`, the
|
|
24
|
+
* gate renders `/sign-in`'s own `SignInCard` — the same providers plus "Email
|
|
25
|
+
* me a sign-in link" — in place of `AuthPanel`, whose email + password form
|
|
26
|
+
* has no submit wiring and would otherwise sit inert beside the new field.
|
|
27
|
+
* Without the prop every render is what it was before.
|
|
17
28
|
*/
|
|
18
29
|
|
|
19
30
|
import { EmptyState, Link } from "@working-theory/ui/primitives";
|
|
@@ -22,12 +33,11 @@ import { createAuthClient } from "better-auth/react";
|
|
|
22
33
|
import { usePathname } from "next/navigation";
|
|
23
34
|
import type { ReactElement } from "react";
|
|
24
35
|
|
|
25
|
-
import {
|
|
36
|
+
import { SignInCard } from "../(auth)/sign-in/sign-in-card";
|
|
37
|
+
import type { SignInCardText } from "../(auth)/sign-in/sign-in-text";
|
|
26
38
|
|
|
27
39
|
const authClient = createAuthClient();
|
|
28
40
|
|
|
29
|
-
const SUPPORTED_PROVIDERS: readonly AuthPanelProvider[] = ["google", "github"];
|
|
30
|
-
|
|
31
41
|
export interface SignInGateLabels {
|
|
32
42
|
emptyHeadline: string;
|
|
33
43
|
emptyDescription: string;
|
|
@@ -39,18 +49,27 @@ export interface SignInGateProps {
|
|
|
39
49
|
labels: SignInGateLabels;
|
|
40
50
|
/** Locale-prefixed sign-in page path — `localeHref(locale, "/sign-in")`. */
|
|
41
51
|
signInPath: string;
|
|
52
|
+
/** The active social providers, resolved server-side by the layout (#7419). */
|
|
53
|
+
providers: AuthPanelProvider[];
|
|
54
|
+
/**
|
|
55
|
+
* Present iff the email sign-in link is on (#7435) — the sign-in card's
|
|
56
|
+
* copy, resolved server-side by the layout.
|
|
57
|
+
*/
|
|
58
|
+
emailLink?: { text: SignInCardText };
|
|
42
59
|
}
|
|
43
60
|
|
|
44
|
-
export function SignInGate({
|
|
61
|
+
export function SignInGate({
|
|
62
|
+
labels,
|
|
63
|
+
signInPath,
|
|
64
|
+
providers,
|
|
65
|
+
emailLink,
|
|
66
|
+
}: SignInGateProps): ReactElement {
|
|
45
67
|
const pathname = usePathname();
|
|
46
|
-
const
|
|
47
|
-
(id): id is AuthPanelProvider =>
|
|
48
|
-
(SUPPORTED_PROVIDERS as readonly string[]).includes(id),
|
|
49
|
-
);
|
|
68
|
+
const signInHref = `${signInPath}?next=${encodeURIComponent(pathname)}`;
|
|
50
69
|
|
|
51
70
|
const signInLink = (
|
|
52
71
|
<Link
|
|
53
|
-
href={
|
|
72
|
+
href={signInHref}
|
|
54
73
|
data-testid="sign-in-gate-link"
|
|
55
74
|
// 24px hit-area floor (Rule 14) — a bare inline link's line-height box
|
|
56
75
|
// falls short on its own.
|
|
@@ -60,6 +79,30 @@ export function SignInGate({ labels, signInPath }: SignInGateProps): ReactElemen
|
|
|
60
79
|
</Link>
|
|
61
80
|
);
|
|
62
81
|
|
|
82
|
+
if (emailLink) {
|
|
83
|
+
const { text } = emailLink;
|
|
84
|
+
return (
|
|
85
|
+
<div
|
|
86
|
+
className="flex min-h-screen flex-col items-center justify-center gap-4 py-section"
|
|
87
|
+
data-testid="sign-in-gate"
|
|
88
|
+
>
|
|
89
|
+
<SignInCard
|
|
90
|
+
providers={providers}
|
|
91
|
+
method="sso"
|
|
92
|
+
next={pathname}
|
|
93
|
+
labels={text.labels}
|
|
94
|
+
notConfiguredSubhead={text.notConfiguredSubhead}
|
|
95
|
+
errorLabels={text.errorLabels}
|
|
96
|
+
emailLink
|
|
97
|
+
errorCallbackURL={signInHref}
|
|
98
|
+
signInHref={signInHref}
|
|
99
|
+
emailLinkLabels={text.emailLinkLabels}
|
|
100
|
+
/>
|
|
101
|
+
{signInLink}
|
|
102
|
+
</div>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
63
106
|
if (providers.length === 0) {
|
|
64
107
|
return (
|
|
65
108
|
<div
|
|
@@ -12,18 +12,31 @@
|
|
|
12
12
|
|
|
13
13
|
import { captureLeadAction } from "../contact.actions.js";
|
|
14
14
|
|
|
15
|
-
// Mock @working-theory/api-client
|
|
15
|
+
// Mock @working-theory/api-client. `visitorForwardHeaders` stays real (#7394)
|
|
16
|
+
// so AC-9 compares the action's `init` with the helper's own output.
|
|
16
17
|
jest.mock("@working-theory/api-client", () => ({
|
|
17
18
|
leads: {
|
|
18
19
|
submit: jest.fn(),
|
|
19
20
|
},
|
|
21
|
+
visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
|
|
22
|
+
.visitorForwardHeaders,
|
|
20
23
|
}));
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
// next/headers' `headers()` is request-scoped in Next; outside it, it is
|
|
26
|
+
// mocked here the way flow.actions.test.ts does (#7394 AC-9).
|
|
27
|
+
jest.mock("next/headers", () => ({
|
|
28
|
+
__esModule: true,
|
|
29
|
+
headers: jest.fn().mockResolvedValue({ get: () => null }),
|
|
30
|
+
}));
|
|
31
|
+
|
|
32
|
+
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
33
|
+
import { headers } from "next/headers";
|
|
23
34
|
const mockSubmit = leads.submit as jest.Mock;
|
|
35
|
+
const mockHeaders = headers as jest.Mock;
|
|
24
36
|
|
|
25
37
|
afterEach(() => {
|
|
26
38
|
jest.clearAllMocks();
|
|
39
|
+
mockHeaders.mockResolvedValue({ get: () => null });
|
|
27
40
|
});
|
|
28
41
|
|
|
29
42
|
function makeFormData(fields: Record<string, string>): FormData {
|
|
@@ -308,3 +321,51 @@ describe("captureLeadAction — honeypot (#6730)", () => {
|
|
|
308
321
|
expect(body.payload.website).toBe("https://acme.example");
|
|
309
322
|
});
|
|
310
323
|
});
|
|
324
|
+
|
|
325
|
+
// #7394 AC-9 — a spoke forwards its visitor to the hub's leads rate limit.
|
|
326
|
+
describe("captureLeadAction — visitor forwarding (#7394)", () => {
|
|
327
|
+
const FORWARD_KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
|
|
328
|
+
const ENV_KEYS = ["AILK_VISITOR_FORWARD_KEY", "AILK_API_URL"] as const;
|
|
329
|
+
const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
330
|
+
|
|
331
|
+
beforeEach(() => {
|
|
332
|
+
process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
|
|
333
|
+
process.env.AILK_API_URL = "https://hub.example";
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
afterAll(() => {
|
|
337
|
+
for (const [name, value] of priorEnv) {
|
|
338
|
+
if (value === undefined) delete process.env[name];
|
|
339
|
+
else process.env[name] = value;
|
|
340
|
+
}
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("passes the helper's two headers as init", async () => {
|
|
344
|
+
const incoming = {
|
|
345
|
+
get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.5" : null),
|
|
346
|
+
};
|
|
347
|
+
mockHeaders.mockResolvedValue(incoming);
|
|
348
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
349
|
+
|
|
350
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
351
|
+
|
|
352
|
+
expect(result).toEqual({ kind: "success" });
|
|
353
|
+
const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
|
|
354
|
+
expect(expected).toEqual({
|
|
355
|
+
"x-ailk-visitor-ip": "203.0.113.5",
|
|
356
|
+
"x-ailk-visitor-forward-key": FORWARD_KEY,
|
|
357
|
+
});
|
|
358
|
+
expect(mockSubmit).toHaveBeenCalledWith(expect.any(Object), { headers: expected });
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("calls leads.submit with one argument when headers() throws, and still succeeds", async () => {
|
|
362
|
+
mockHeaders.mockRejectedValue(new Error("outside a request scope"));
|
|
363
|
+
mockSubmit.mockResolvedValueOnce({ ok: true, body: { id: "lead_1", persisted: "db" } });
|
|
364
|
+
|
|
365
|
+
const result = await captureLeadAction(INITIAL_STATE, makeFormData(VALID_FIELDS));
|
|
366
|
+
|
|
367
|
+
expect(result).toEqual({ kind: "success" });
|
|
368
|
+
expect(mockSubmit).toHaveBeenCalledTimes(1);
|
|
369
|
+
expect(mockSubmit.mock.calls[0]).toHaveLength(1);
|
|
370
|
+
});
|
|
371
|
+
});
|
|
@@ -35,11 +35,20 @@
|
|
|
35
35
|
* a capability (lead capture) this repo already has an MCP twin for
|
|
36
36
|
* (`ailkCaptureLead`). Absent (the real `<form>` never sets it) → defaults
|
|
37
37
|
* to `"web"`, unchanged from before.
|
|
38
|
+
*
|
|
39
|
+
* Visitor forwarding (#7394): on a spoke whose API is a hub, the call
|
|
40
|
+
* carries the visitor's IP and the spoke's `AILK_VISITOR_FORWARD_KEY`
|
|
41
|
+
* (server-only, never `NEXT_PUBLIC_`) via `visitorForwardHeaders`, so the
|
|
42
|
+
* hub counts this lead against the visitor's own rate-limit budget instead
|
|
43
|
+
* of the spoke host's. No key, no client address, or headers that cannot be
|
|
44
|
+
* read → the call is made with one argument, exactly as before; a failure to
|
|
45
|
+
* read headers never blocks a submission.
|
|
38
46
|
*/
|
|
39
47
|
|
|
40
|
-
import { leads } from "@working-theory/api-client";
|
|
48
|
+
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
41
49
|
import { LEAD_CAPTURE_HONEYPOT, type LeadCaptureFormState } from "@working-theory/ui";
|
|
42
50
|
import { submitLeadInputSchema } from "@working-theory/validation";
|
|
51
|
+
import { headers } from "next/headers";
|
|
43
52
|
|
|
44
53
|
const GENERIC_ERROR = "Something went wrong, please try again.";
|
|
45
54
|
|
|
@@ -131,8 +140,19 @@ export async function captureLeadAction(
|
|
|
131
140
|
},
|
|
132
141
|
};
|
|
133
142
|
|
|
143
|
+
let init: { headers: Record<string, string> } | undefined;
|
|
144
|
+
try {
|
|
145
|
+
const forwarded = visitorForwardHeaders(
|
|
146
|
+
await headers(),
|
|
147
|
+
process.env.AILK_VISITOR_FORWARD_KEY,
|
|
148
|
+
);
|
|
149
|
+
if (forwarded) init = { headers: forwarded };
|
|
150
|
+
} catch {
|
|
151
|
+
// No request scope to read — submit without forwarding.
|
|
152
|
+
}
|
|
153
|
+
|
|
134
154
|
try {
|
|
135
|
-
const res = await leads.submit(body);
|
|
155
|
+
const res = init ? await leads.submit(body, init) : await leads.submit(body);
|
|
136
156
|
if (!res.ok) {
|
|
137
157
|
if (isNotConfigured(res)) return { kind: "unavailable" };
|
|
138
158
|
return { kind: "error", message: GENERIC_ERROR };
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
// Shell/Chrome composition — Nav + Footer wrap via @working-theory/templates MarketingShell (#2820)
|
|
6
6
|
// Session-aware Nav actions — Sign in / Account + Sign out from the server-read session (#4984)
|
|
7
7
|
// Auth route group — the marketing chrome is dropped on the five (auth) paths (#5963)
|
|
8
|
+
// Account area — the marketing chrome AND the consent Banner are dropped on /account paths (#7430)
|
|
8
9
|
// Site-declared theme — `_site.mdx` `theme:` block → token overlay → CSS vars
|
|
9
10
|
//
|
|
10
11
|
// Sits under app/[locale]/ and sets the <html lang> attribute to the
|
|
@@ -52,7 +53,7 @@ import {
|
|
|
52
53
|
SignedInNav,
|
|
53
54
|
type SignedInNavProps,
|
|
54
55
|
} from "../../components/signed-in-nav";
|
|
55
|
-
import { isAuthPath } from "../../lib/auth-routes";
|
|
56
|
+
import { isAccountPath, isAuthPath } from "../../lib/auth-routes";
|
|
56
57
|
import { assertEnabledLocale } from "../../lib/locale-guard";
|
|
57
58
|
import { footerColumns, navItems } from "../../lib/nav-config";
|
|
58
59
|
import { getOgImageUrl } from "../../lib/og";
|
|
@@ -395,6 +396,14 @@ export default async function LocaleLayout({
|
|
|
395
396
|
// `(marketing)` group — a ~35-directory move for a five-page delta (spec D1).
|
|
396
397
|
const isAuthRoute = isAuthPath(pathname, enabledLocales);
|
|
397
398
|
|
|
399
|
+
// The account branch (#7430, spec 7423 D4) — the same mechanism for the
|
|
400
|
+
// signed-in account area, which renders inside its own SignedInShell: no
|
|
401
|
+
// Nav, no Footer, no announcement Banner, and no cookie consent Banner
|
|
402
|
+
// either (the auth branch keeps that one). Cookie settings stay reachable
|
|
403
|
+
// from User settings › Privacy (`/privacy#cookie-settings`). The same
|
|
404
|
+
// fail-safe direction: an absent `x-pathname` keeps the full frame.
|
|
405
|
+
const isAccountRoute = isAccountPath(pathname, enabledLocales);
|
|
406
|
+
|
|
398
407
|
// Every Nav prop that is safe to serialize — shared verbatim by the
|
|
399
408
|
// server-rendered Nav (signed out) and `SignedInNav` (signed in), which adds
|
|
400
409
|
// `navItems` and `renderFlowTrigger` on its own side of the RSC boundary.
|
|
@@ -553,14 +562,16 @@ export default async function LocaleLayout({
|
|
|
553
562
|
* emitted unconditionally in OSS forks; paying customers install
|
|
554
563
|
* @working-theory/branding and gate it in their own layout.
|
|
555
564
|
*/}
|
|
556
|
-
{isAuthRoute ? (
|
|
565
|
+
{isAuthRoute || isAccountRoute ? (
|
|
557
566
|
/*
|
|
558
567
|
* The (auth) branch (#5963): the five auth paths render their own
|
|
559
568
|
* `(auth)/layout.tsx` — AuthShell — with no Nav and no marketing
|
|
560
569
|
* Footer above or below it. Everything outside this ternary (the
|
|
561
570
|
* document, the flash-prevention script, the theme vars, the
|
|
562
571
|
* favicons, NextIntlClientProvider, the consent Banner and
|
|
563
|
-
* Telemetry) is shared by both branches and written once.
|
|
572
|
+
* Telemetry) is shared by both branches and written once. The
|
|
573
|
+
* account paths (#7430) take this branch too, and also skip the
|
|
574
|
+
* consent Banner below.
|
|
564
575
|
*/
|
|
565
576
|
children
|
|
566
577
|
) : (
|
|
@@ -673,31 +684,33 @@ export default async function LocaleLayout({
|
|
|
673
684
|
* reserves its own space (D1) and carries a close-without-choosing
|
|
674
685
|
* control (D2) instead of a hard server-side hide.
|
|
675
686
|
*/}
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
687
|
+
{isAccountRoute ? null : (
|
|
688
|
+
<Banner
|
|
689
|
+
variant="info"
|
|
690
|
+
placement="bottom"
|
|
691
|
+
consentCookieName={CONSENT_COOKIE_NAME}
|
|
692
|
+
consentDecided={consentDecided}
|
|
693
|
+
title={tConsent("title")}
|
|
694
|
+
message={
|
|
695
|
+
<>
|
|
696
|
+
{tConsent("body")}{" "}
|
|
697
|
+
<Link
|
|
698
|
+
href={localeHref(locale, "/privacy")}
|
|
699
|
+
variant="unstyled"
|
|
700
|
+
className="font-semibold text-current underline underline-offset-4"
|
|
701
|
+
>
|
|
702
|
+
{tConsent("policyLinkLabel")}
|
|
703
|
+
</Link>
|
|
704
|
+
</>
|
|
705
|
+
}
|
|
706
|
+
dismissible={false}
|
|
707
|
+
dismissLabel={tConsent("dismissLabel")}
|
|
708
|
+
acceptLabel={tConsent("acceptLabel")}
|
|
709
|
+
secondaryAction={{ label: tConsent("rejectLabel") }}
|
|
710
|
+
rejectLabel={tConsent("rejectLabel")}
|
|
711
|
+
locale={locale}
|
|
712
|
+
/>
|
|
713
|
+
)}
|
|
701
714
|
<Telemetry />
|
|
702
715
|
</NextIntlClientProvider>
|
|
703
716
|
</body>
|
|
@@ -5,12 +5,25 @@
|
|
|
5
5
|
|
|
6
6
|
import { subscribeNewsletterAction } from "../newsletter.actions.js";
|
|
7
7
|
|
|
8
|
+
// `visitorForwardHeaders` stays real (#7394) so AC-9 compares the action's
|
|
9
|
+
// `init` with the helper's own output.
|
|
8
10
|
jest.mock("@working-theory/api-client", () => ({
|
|
9
11
|
leads: { subscribe: jest.fn() },
|
|
12
|
+
visitorForwardHeaders: jest.requireActual("@working-theory/api-client")
|
|
13
|
+
.visitorForwardHeaders,
|
|
10
14
|
}));
|
|
11
15
|
|
|
12
|
-
|
|
16
|
+
// next/headers' `headers()` is request-scoped in Next; outside it, it is
|
|
17
|
+
// mocked here the way flow.actions.test.ts does (#7394 AC-9).
|
|
18
|
+
jest.mock("next/headers", () => ({
|
|
19
|
+
__esModule: true,
|
|
20
|
+
headers: jest.fn().mockResolvedValue({ get: () => null }),
|
|
21
|
+
}));
|
|
22
|
+
|
|
23
|
+
import { leads, visitorForwardHeaders } from "@working-theory/api-client";
|
|
24
|
+
import { headers } from "next/headers";
|
|
13
25
|
const mockSubscribe = leads.subscribe as jest.Mock;
|
|
26
|
+
const mockHeaders = headers as jest.Mock;
|
|
14
27
|
|
|
15
28
|
function formWith(email: string): FormData {
|
|
16
29
|
const fd = new FormData();
|
|
@@ -19,7 +32,10 @@ function formWith(email: string): FormData {
|
|
|
19
32
|
return fd;
|
|
20
33
|
}
|
|
21
34
|
|
|
22
|
-
afterEach(() =>
|
|
35
|
+
afterEach(() => {
|
|
36
|
+
jest.clearAllMocks();
|
|
37
|
+
mockHeaders.mockResolvedValue({ get: () => null });
|
|
38
|
+
});
|
|
23
39
|
|
|
24
40
|
describe("subscribeNewsletterAction", () => {
|
|
25
41
|
it("calls leads.subscribe with source newsletter and the address", async () => {
|
|
@@ -73,3 +89,51 @@ describe("subscribeNewsletterAction", () => {
|
|
|
73
89
|
expect(mockSubscribe).not.toHaveBeenCalled();
|
|
74
90
|
});
|
|
75
91
|
});
|
|
92
|
+
|
|
93
|
+
// #7394 AC-9 — a spoke forwards its visitor to the hub's leads rate limit.
|
|
94
|
+
describe("subscribeNewsletterAction — visitor forwarding (#7394)", () => {
|
|
95
|
+
const FORWARD_KEY = "0123456789abcdef".repeat(4); // gitleaks:allow — test fixture, not a credential (#7394)
|
|
96
|
+
const ENV_KEYS = ["AILK_VISITOR_FORWARD_KEY", "AILK_API_URL"] as const;
|
|
97
|
+
const priorEnv = new Map(ENV_KEYS.map((name) => [name, process.env[name]]));
|
|
98
|
+
|
|
99
|
+
beforeEach(() => {
|
|
100
|
+
process.env.AILK_VISITOR_FORWARD_KEY = FORWARD_KEY;
|
|
101
|
+
process.env.AILK_API_URL = "https://hub.example";
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
afterAll(() => {
|
|
105
|
+
for (const [name, value] of priorEnv) {
|
|
106
|
+
if (value === undefined) delete process.env[name];
|
|
107
|
+
else process.env[name] = value;
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("passes the helper's two headers as init", async () => {
|
|
112
|
+
const incoming = {
|
|
113
|
+
get: (name: string) => (name === "x-forwarded-for" ? "203.0.113.5" : null),
|
|
114
|
+
};
|
|
115
|
+
mockHeaders.mockResolvedValue(incoming);
|
|
116
|
+
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
117
|
+
|
|
118
|
+
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
119
|
+
|
|
120
|
+
expect(result).toEqual({ kind: "success" });
|
|
121
|
+
const expected = visitorForwardHeaders(incoming, FORWARD_KEY);
|
|
122
|
+
expect(expected).toEqual({
|
|
123
|
+
"x-ailk-visitor-ip": "203.0.113.5",
|
|
124
|
+
"x-ailk-visitor-forward-key": FORWARD_KEY,
|
|
125
|
+
});
|
|
126
|
+
expect(mockSubscribe).toHaveBeenCalledWith(expect.any(Object), { headers: expected });
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("calls leads.subscribe with one argument when headers() throws, and still succeeds", async () => {
|
|
130
|
+
mockHeaders.mockRejectedValue(new Error("outside a request scope"));
|
|
131
|
+
mockSubscribe.mockResolvedValueOnce({ ok: true, body: { id: "subscr_1", persisted: "db" } });
|
|
132
|
+
|
|
133
|
+
const result = await subscribeNewsletterAction(null, formWith("ada@example.com"));
|
|
134
|
+
|
|
135
|
+
expect(result).toEqual({ kind: "success" });
|
|
136
|
+
expect(mockSubscribe).toHaveBeenCalledTimes(1);
|
|
137
|
+
expect(mockSubscribe.mock.calls[0]).toHaveLength(1);
|
|
138
|
+
});
|
|
139
|
+
});
|