create-ailk 0.11.1 → 0.11.3
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/content-payloads.js +2 -2
- package/dist/module-architecture.js +11 -0
- package/dist/surfaces.d.ts +4 -5
- package/dist/surfaces.js +50 -8
- package/package.json +3 -3
- package/templates/.env.example +42 -3
- package/templates/apps/api/.env.example +5 -0
- package/templates/apps/api/src/__tests__/cors.test.ts +91 -10
- package/templates/apps/api/src/__tests__/leads-forwarded-rate-limit.test.ts +135 -0
- package/templates/apps/api/src/__tests__/server.test.ts +5 -1
- package/templates/apps/api/src/__tests__/trusted-origins.server.test.ts +135 -0
- package/templates/apps/api/src/__tests__/vercel-handler.test.ts +233 -12
- package/templates/apps/api/src/config/__tests__/trusted-origins.test.ts +238 -0
- package/templates/apps/api/src/config/index.ts +136 -0
- package/templates/apps/api/src/lib/auth-email-budget.ts +3 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/bearer-token-format.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/cookie-or-bearer-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-bearer-skip.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-cookie-path.test.ts +2 -2
- package/templates/apps/api/src/middleware/__tests__/adversarial/csrf-trusted-origins.test.ts +142 -0
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-fixation.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/adversarial/session-timeout.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.both-present-precedence.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.cookie-path.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.database-not-configured.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.neither-present.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/auth.optional-cookie.test.ts +1 -1
- package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +529 -1
- package/templates/apps/api/src/middleware/auth.ts +19 -21
- package/templates/apps/api/src/middleware/rate-limit.ts +341 -28
- package/templates/apps/api/src/routes/auth/__tests__/auth-tokens.functional.test.ts +1 -1
- package/templates/apps/api/src/routes/auth/__tests__/auth-user.functional.test.ts +1 -1
- 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/routes/checkout/__tests__/sessions.test.ts +168 -39
- package/templates/apps/api/src/routes/checkout/sessions.ts +20 -34
- package/templates/apps/api/src/routes/project-listings/__tests__/me.test.ts +1 -1
- package/templates/apps/api/src/server.ts +49 -16
- 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 +297 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +145 -234
- 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 +143 -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 +88 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +120 -297
- 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 +98 -84
- 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 +102 -18
- package/templates/apps/web/components/sign-out-trigger.tsx +3 -2
- package/templates/apps/web/components/signed-in-nav.tsx +58 -0
- 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 +147 -21
- package/templates/database/CHANGELOG.md +7 -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 };
|