From 667bf06bb0ecb79295329e807c24e9af622e917d Mon Sep 17 00:00:00 2001 From: Josh Zhang <39790535+jiashuoz@users.noreply.github.com> Date: Sun, 27 Sep 2026 17:52:26 +0800 Subject: [PATCH] feat(web): config-gated Privacy and Terms links plus sign-in consent line The hosted deployment's terms of service and privacy policy pages are currently linked nowhere on the site or dashboard, and the sign-in flow shows no consent line. Add NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL to lib/site.ts, following the PRICING_PATH pattern: empty by default so self-host and staging builds are unaffected, populated by the hosted deployment at image build time. - Landing page footer, plus the blog/docs/mcp footers via a shared LegalFooterLinks helper, show "Privacy"/"Terms" only when configured. - A shared SignInConsent component renders "By signing in you agree to the Terms and Privacy policy." beneath the landing page's Sign in link and the dashboard's signed-out sign-in prompt, only when both URLs are set. - Links accept a same-origin path or an absolute URL; isExternalURL() decides target=_blank based on origin vs SITE_URL. Co-Authored-By: Claude Fable 5.1 --- docs/deployment.md | 2 + web/.env.example | 10 ++ web/src/app/(app)/AppLayoutClient.tsx | 5 + .../layout.legal-consent.hosted.test.tsx | 68 ++++++++++ web/src/app/(app)/layout.test.tsx | 3 + web/src/app/blog/layout.tsx | 5 + .../app/components/LegalFooterLinks.test.tsx | 75 +++++++++++ web/src/app/components/LegalFooterLinks.tsx | 39 ++++++ web/src/app/components/SignInConsent.test.tsx | 124 ++++++++++++++++++ web/src/app/components/SignInConsent.tsx | 50 +++++++ web/src/app/docs/page.test.tsx | 9 ++ web/src/app/docs/page.tsx | 5 + web/src/app/mcp/page.tsx | 5 + web/src/app/page.legal-links.hosted.test.tsx | 66 ++++++++++ web/src/app/page.test.tsx | 19 +++ web/src/app/page.tsx | 34 +++-- web/src/lib/site.test.ts | 90 +++++++++++++ web/src/lib/site.ts | 51 +++++++ 18 files changed, 651 insertions(+), 9 deletions(-) create mode 100644 web/src/app/(app)/layout.legal-consent.hosted.test.tsx create mode 100644 web/src/app/components/LegalFooterLinks.test.tsx create mode 100644 web/src/app/components/LegalFooterLinks.tsx create mode 100644 web/src/app/components/SignInConsent.test.tsx create mode 100644 web/src/app/components/SignInConsent.tsx create mode 100644 web/src/app/page.legal-links.hosted.test.tsx diff --git a/docs/deployment.md b/docs/deployment.md index 1bef5874e..467577f03 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -150,6 +150,8 @@ The Next.js dashboard ships as a static export, so its config is inlined at buil | `NEXT_PUBLIC_GOOGLE_SITE_VERIFICATION` | Google Search Console token. Only emitted into `` when set, so forks don't inherit upstream's property. | | `NEXT_PUBLIC_PRICING_PATH` | Site-relative path of the pricing page, when the deployment serves one (the hosted deployment sets `/pricing`). Only used to add the page to the sitemap. Leave empty if there is no pricing route — a sitemap entry that 404s is a crawl-quality problem. | | `NEXT_PUBLIC_E2A_SIGN_IN_URL` | Sign-in door for the dashboard's "Sign in" links. Default: `/api/auth/login` (legacy Google OAuth). Set to `/api/auth/oidc/login` to make the generic OIDC door the default — only when the server runs with `E2A_OIDC_ENABLED=true`, otherwise the link 404s. OIDC copy remains provider-neutral for self-hosters; the upstream `e2a.dev` and `staging.e2a.dev` site builds identify their door as TokenCanopy. When OIDC is primary, signed-out app and OAuth-consent screens also show the legacy Google door as a fallback. | +| `NEXT_PUBLIC_PRIVACY_URL` | Site-relative path or absolute URL of the deployment's Privacy Policy, when it has one (the hosted deployment's `legal/` pages live in the private ops repo). Adds a "Privacy" link to public-page footers and, together with `NEXT_PUBLIC_TERMS_URL`, a consent line under the sign-in call to action. Leave empty if there's no privacy page. | +| `NEXT_PUBLIC_TERMS_URL` | Same as `NEXT_PUBLIC_PRIVACY_URL`, for the Terms of Service page. The sign-in consent line ("By signing in you agree to the Terms and Privacy policy.") only renders when **both** URLs are set — one without the other would either link nowhere or assert a policy that doesn't exist. | ## MCP HTTP server diff --git a/web/.env.example b/web/.env.example index 3288346ab..fd3081f5e 100644 --- a/web/.env.example +++ b/web/.env.example @@ -50,3 +50,13 @@ NEXT_PUBLIC_PRICING_PATH= # legacy door and provider-neutral "Sign in" for OIDC. The upstream hosted # e2a.dev builds identify their OIDC door as TokenCanopy using their site URL. NEXT_PUBLIC_E2A_SIGN_IN_URL= + +# Optional. Site-relative path or absolute URL of the deployment's Privacy +# Policy and Terms of Service pages. Neither is part of this app — like +# pricing, the hosted deployment's legal pages live in the private ops repo +# and these get baked in at image build time. Leave both empty for a +# self-host or staging build: the footer links and the sign-in consent line +# both stay hidden rather than pointing at a 404. The consent line only +# appears when BOTH are set. +NEXT_PUBLIC_PRIVACY_URL= +NEXT_PUBLIC_TERMS_URL= diff --git a/web/src/app/(app)/AppLayoutClient.tsx b/web/src/app/(app)/AppLayoutClient.tsx index 34b8e9c65..88eefacfc 100644 --- a/web/src/app/(app)/AppLayoutClient.tsx +++ b/web/src/app/(app)/AppLayoutClient.tsx @@ -7,6 +7,7 @@ import { useAuth } from "../components/AuthProvider"; import { SWRProvider } from "../components/swr/SWRProvider"; import { PendingPollingOwner } from "../components/swr/PendingPollingOwner"; import { SignInLinks } from "../components/SignInLinks"; +import { SignInConsent } from "../components/SignInConsent"; import { RestoredNotice } from "../components/RestoredNotice"; import { ReadOnlyBanner } from "../components/ReadOnlyBanner"; import { Sidebar } from "../components/loft/Sidebar"; @@ -141,6 +142,10 @@ export default function AppLayout({ secondaryClassName="text-[13px] underline underline-offset-2" secondaryStyle={{ color: "var(--fg-muted)" }} /> + diff --git a/web/src/app/(app)/layout.legal-consent.hosted.test.tsx b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx new file mode 100644 index 000000000..12983f6f3 --- /dev/null +++ b/web/src/app/(app)/layout.legal-consent.hosted.test.tsx @@ -0,0 +1,68 @@ +/** + * Hosted-build branch of the dashboard's signed-out sign-in consent line: + * NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL are inlined at build time + * and read at module load (lib/site.PRIVACY_URL / TERMS_URL), so this file + * sets both env vars BEFORE importing AppLayoutClient. The unset/self-host + * branch (no consent line) is asserted in layout.test.tsx, where jest runs + * with the env vars absent. + */ +process.env.NEXT_PUBLIC_PRIVACY_URL = "/privacy"; +process.env.NEXT_PUBLIC_TERMS_URL = "/terms"; + +import { render, screen, within } from "@testing-library/react"; +import AppLayout from "./AppLayoutClient"; + +jest.mock("next/link", () => { + return function MockLink({ + href, + children, + ...rest + }: { + href: string; + children: React.ReactNode; + [k: string]: unknown; + }) { + return ( + + {children} + + ); + }; +}); + +jest.mock("next/navigation", () => ({ + usePathname: () => "/inboxes", + useRouter: () => ({ replace: jest.fn(), push: jest.fn(), back: jest.fn() }), +})); + +jest.mock("../components/AuthProvider", () => ({ + useAuth: () => ({ user: null, loading: false }), +})); + +jest.mock("../components/loft/Sidebar", () => ({ + Sidebar: () => null, +})); +jest.mock("../components/swr/PendingPollingOwner", () => ({ + PendingPollingOwner: () => null, +})); + +describe("(app) layout — sign-in consent (hosted build)", () => { + it("renders the consent line beneath the sign-in links when both legal URLs are configured", () => { + render( + +
page content
+
, + ); + const consent = screen.getByTestId("sign-in-consent"); + expect(consent).toHaveTextContent( + "By signing in you agree to the Terms and Privacy policy.", + ); + expect(within(consent).getByRole("link", { name: "Terms" })).toHaveAttribute( + "href", + "/terms", + ); + expect( + within(consent).getByRole("link", { name: "Privacy policy" }), + ).toHaveAttribute("href", "/privacy"); + }); +}); diff --git a/web/src/app/(app)/layout.test.tsx b/web/src/app/(app)/layout.test.tsx index f510eb3aa..31b0366de 100644 --- a/web/src/app/(app)/layout.test.tsx +++ b/web/src/app/(app)/layout.test.tsx @@ -84,6 +84,9 @@ describe("(app) layout — auth gates", () => { expect(screen.getByRole("link", { name: "Sign in with Google" })) .toHaveAttribute("href", "/api/auth/login"); expect(screen.queryByText("page content")).not.toBeInTheDocument(); + // No legal URLs configured in jest (self-host/staging default) — the + // hosted branch is covered in layout.legal-consent.hosted.test.tsx. + expect(screen.queryByTestId("sign-in-consent")).not.toBeInTheDocument(); }); it("preserves a review deep link through sign-in", async () => { diff --git a/web/src/app/blog/layout.tsx b/web/src/app/blog/layout.tsx index 7b733dac1..a6c1878dd 100644 --- a/web/src/app/blog/layout.tsx +++ b/web/src/app/blog/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { SITE_URL } from "../../lib/site"; +import { LegalFooterLinks } from "../components/LegalFooterLinks"; const TITLE = "Blog — e2a, email for AI agents"; const DESC = @@ -90,6 +91,10 @@ export default function BlogLayout({ children }: { children: React.ReactNode }) > e2a + { + return function MockLink({ + href, + children, + ...props + }: { + href: string; + children: React.ReactNode; + [key: string]: unknown; + }) { + return ( + + {children} + + ); + }; +}); + +function loadLegalFooterLinks(): typeof import("./LegalFooterLinks") { + let mod: typeof import("./LegalFooterLinks") | undefined; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + mod = require("./LegalFooterLinks"); + }); + if (!mod) throw new Error("failed to load ./LegalFooterLinks"); + return mod; +} + +describe("LegalFooterLinks", () => { + const originalPrivacy = process.env[PRIVACY_ENV_KEY]; + const originalTerms = process.env[TERMS_ENV_KEY]; + + afterEach(() => { + if (originalPrivacy === undefined) delete process.env[PRIVACY_ENV_KEY]; + else process.env[PRIVACY_ENV_KEY] = originalPrivacy; + if (originalTerms === undefined) delete process.env[TERMS_ENV_KEY]; + else process.env[TERMS_ENV_KEY] = originalTerms; + }); + + it("renders nothing on a self-host/staging build with neither URL configured", () => { + delete process.env[PRIVACY_ENV_KEY]; + delete process.env[TERMS_ENV_KEY]; + const { LegalFooterLinks } = loadLegalFooterLinks(); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders Privacy and Terms links when both are configured", () => { + process.env[PRIVACY_ENV_KEY] = "/privacy"; + process.env[TERMS_ENV_KEY] = "/terms"; + const { LegalFooterLinks } = loadLegalFooterLinks(); + render(); + expect(screen.getByRole("link", { name: "Privacy" })).toHaveAttribute( + "href", + "/privacy", + ); + expect(screen.getByRole("link", { name: "Terms" })).toHaveAttribute( + "href", + "/terms", + ); + }); +}); diff --git a/web/src/app/components/LegalFooterLinks.tsx b/web/src/app/components/LegalFooterLinks.tsx new file mode 100644 index 000000000..d3d3b4efe --- /dev/null +++ b/web/src/app/components/LegalFooterLinks.tsx @@ -0,0 +1,39 @@ +import Link from "next/link"; +import { legalFooterLinks } from "../../lib/site"; + +// Privacy/Terms links for a public-page footer that doesn't already carry +// its own link array (blog, docs, mcp — the landing page instead spreads +// legalFooterLinks() straight into its own FOOTER_LINKS). Renders nothing +// when neither PRIVACY_URL nor TERMS_URL is configured, so a self-host or +// staging footer looks exactly as it did before this existed. +export function LegalFooterLinks({ + className, + linkStyle, +}: { + className?: string; + linkStyle?: React.CSSProperties; +}) { + const links = legalFooterLinks(); + if (links.length === 0) return null; + return ( +
+ {links.map((l) => + l.external ? ( + + {l.label} + + ) : ( + + {l.label} + + ), + )} +
+ ); +} diff --git a/web/src/app/components/SignInConsent.test.tsx b/web/src/app/components/SignInConsent.test.tsx new file mode 100644 index 000000000..bcd702f3f --- /dev/null +++ b/web/src/app/components/SignInConsent.test.tsx @@ -0,0 +1,124 @@ +// SignInConsent reads NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL through +// lib/site, which resolves env vars at module load (Next.js inlines them at +// build time). Each scenario sets the env vars and re-imports the module +// tree fresh — see lib/site.test.ts for the same isolateModules pattern. + +import { render, screen, within } from "@testing-library/react"; + +export {}; + +const PRIVACY_ENV_KEY = "NEXT_PUBLIC_PRIVACY_URL"; +const TERMS_ENV_KEY = "NEXT_PUBLIC_TERMS_URL"; + +jest.mock("next/link", () => { + return function MockLink({ + href, + children, + ...props + }: { + href: string; + children: React.ReactNode; + [key: string]: unknown; + }) { + return ( + + {children} + + ); + }; +}); + +function loadSignInConsent(): typeof import("./SignInConsent") { + let mod: typeof import("./SignInConsent") | undefined; + jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + mod = require("./SignInConsent"); + }); + if (!mod) throw new Error("failed to load ./SignInConsent"); + return mod; +} + +describe("SignInConsent", () => { + const originalPrivacy = process.env[PRIVACY_ENV_KEY]; + const originalTerms = process.env[TERMS_ENV_KEY]; + + afterEach(() => { + if (originalPrivacy === undefined) delete process.env[PRIVACY_ENV_KEY]; + else process.env[PRIVACY_ENV_KEY] = originalPrivacy; + if (originalTerms === undefined) delete process.env[TERMS_ENV_KEY]; + else process.env[TERMS_ENV_KEY] = originalTerms; + }); + + it("renders nothing when neither URL is configured", () => { + delete process.env[PRIVACY_ENV_KEY]; + delete process.env[TERMS_ENV_KEY]; + const { SignInConsent } = loadSignInConsent(); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when only the privacy URL is configured", () => { + process.env[PRIVACY_ENV_KEY] = "/privacy"; + delete process.env[TERMS_ENV_KEY]; + const { SignInConsent } = loadSignInConsent(); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders nothing when only the terms URL is configured", () => { + delete process.env[PRIVACY_ENV_KEY]; + process.env[TERMS_ENV_KEY] = "/terms"; + const { SignInConsent } = loadSignInConsent(); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders the exact consent wording with links to both pages when both are configured", () => { + process.env[PRIVACY_ENV_KEY] = "/privacy"; + process.env[TERMS_ENV_KEY] = "/terms"; + const { SignInConsent } = loadSignInConsent(); + render(); + const consent = screen.getByTestId("sign-in-consent"); + expect(consent).toHaveTextContent( + "By signing in you agree to the Terms and Privacy policy.", + ); + expect(within(consent).getByRole("link", { name: "Terms" })).toHaveAttribute( + "href", + "/terms", + ); + expect( + within(consent).getByRole("link", { name: "Privacy policy" }), + ).toHaveAttribute("href", "/privacy"); + }); + + it("renders a same-origin path as an in-app link with no target", () => { + process.env[PRIVACY_ENV_KEY] = "/privacy"; + process.env[TERMS_ENV_KEY] = "/terms"; + const { SignInConsent } = loadSignInConsent(); + render(); + const consent = screen.getByTestId("sign-in-consent"); + expect( + within(consent).getByRole("link", { name: "Terms" }), + ).not.toHaveAttribute("target"); + }); + + it("opens an absolute cross-origin legal URL in a new tab", () => { + process.env[PRIVACY_ENV_KEY] = "https://legal.example.com/privacy"; + process.env[TERMS_ENV_KEY] = "/terms"; + const { SignInConsent } = loadSignInConsent(); + render(); + const consent = screen.getByTestId("sign-in-consent"); + const privacyLink = within(consent).getByRole("link", { + name: "Privacy policy", + }); + expect(privacyLink).toHaveAttribute( + "href", + "https://legal.example.com/privacy", + ); + expect(privacyLink).toHaveAttribute("target", "_blank"); + expect(privacyLink).toHaveAttribute("rel", "noopener noreferrer"); + + const termsLink = within(consent).getByRole("link", { name: "Terms" }); + expect(termsLink).not.toHaveAttribute("target"); + }); +}); diff --git a/web/src/app/components/SignInConsent.tsx b/web/src/app/components/SignInConsent.tsx new file mode 100644 index 000000000..11c6a582a --- /dev/null +++ b/web/src/app/components/SignInConsent.tsx @@ -0,0 +1,50 @@ +import Link from "next/link"; +import { PRIVACY_URL, TERMS_URL } from "../../lib/site"; + +// Rendered directly beneath the primary "Sign in" call to action — the +// landing page nav and the dashboard's signed-out screen both use this so +// the wording and gating logic live in exactly one place. +// +// Both legal pages are hosted-deployment-only (see PRIVACY_URL / TERMS_URL +// in lib/site): a self-host or staging build that hasn't configured both +// renders nothing here at all, rather than a half sentence or a link into a +// 404. It is deliberately all-or-nothing — one URL without the other would +// either link "Terms" nowhere or assert a policy that doesn't exist. +export function SignInConsent({ + className, + style, +}: { + className?: string; + style?: React.CSSProperties; +}) { + if (!PRIVACY_URL || !TERMS_URL) return null; + return ( +

+ By signing in you agree to the{" "} + Terms and{" "} + Privacy policy. +

+ ); +} + +function LegalAnchor({ + href, + children, +}: { + href: string; + children: React.ReactNode; +}) { + const style: React.CSSProperties = { textDecoration: "underline" }; + if (href.startsWith("/")) { + return ( + + {children} + + ); + } + return ( + + {children} + + ); +} diff --git a/web/src/app/docs/page.test.tsx b/web/src/app/docs/page.test.tsx index f383b9b59..1b16d106b 100644 --- a/web/src/app/docs/page.test.tsx +++ b/web/src/app/docs/page.test.tsx @@ -106,4 +106,13 @@ describe("Docs page", () => { expect(names).not.toContain("How do I give my AI agent an email address?"); expect(names).not.toContain("Which MCP clients does e2a work with?"); }); + + // No legal URLs configured in jest (self-host/staging default) — the + // shared LegalFooterLinks helper (see lib/site.ts) must render nothing. + // Its "both configured" branch is covered by its own component tests. + it("renders no Privacy or Terms links when no legal URLs are configured", () => { + render(); + expect(screen.queryByRole("link", { name: "Privacy" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Terms" })).toBeNull(); + }); }); diff --git a/web/src/app/docs/page.tsx b/web/src/app/docs/page.tsx index 8ec558386..d4d654034 100644 --- a/web/src/app/docs/page.tsx +++ b/web/src/app/docs/page.tsx @@ -1,5 +1,6 @@ import Link from "next/link"; import { JsonLd } from "../components/JsonLd"; +import { LegalFooterLinks } from "../components/LegalFooterLinks"; import { breadcrumbs, faqPage, type FaqEntry } from "../../lib/jsonld"; // Deliberately a server component with no "use client". This route used to be @@ -249,6 +250,10 @@ export default function DocsPage() { > e2a
+ Apache 2.0 diff --git a/web/src/app/mcp/page.tsx b/web/src/app/mcp/page.tsx index 24bcdaa95..63f543500 100644 --- a/web/src/app/mcp/page.tsx +++ b/web/src/app/mcp/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { SITE_URL } from "../../lib/site"; import { JsonLd } from "../components/JsonLd"; +import { LegalFooterLinks } from "../components/LegalFooterLinks"; import { breadcrumbs, faqPage, howTo, type FaqEntry } from "../../lib/jsonld"; // Deliberately a server component with no "use client". Everything on this @@ -358,6 +359,10 @@ export default function McpPage() { > e2a + Apache 2.0 diff --git a/web/src/app/page.legal-links.hosted.test.tsx b/web/src/app/page.legal-links.hosted.test.tsx new file mode 100644 index 000000000..73c1dcf03 --- /dev/null +++ b/web/src/app/page.legal-links.hosted.test.tsx @@ -0,0 +1,66 @@ +/** + * Hosted-build branch of the Privacy/Terms footer links and sign-in consent + * line: NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL are inlined at build + * time and read at module load (lib/site.PRIVACY_URL / TERMS_URL), so this + * file sets both env vars BEFORE importing the page. The unset/self-host + * branch (no links, no consent line) is asserted in page.test.tsx, where + * jest runs with the env vars absent — see page.pricing-nav.hosted.test.tsx + * for the same pattern applied to NEXT_PUBLIC_PRICING_PATH. + */ +process.env.NEXT_PUBLIC_PRIVACY_URL = "/privacy"; +process.env.NEXT_PUBLIC_TERMS_URL = "/terms"; + +import { render, screen, within } from "@testing-library/react"; +import Home from "./page"; + +jest.mock("next/link", () => { + return function MockLink({ + href, + children, + ...props + }: { + href: string; + children: React.ReactNode; + [key: string]: unknown; + }) { + return ( + + {children} + + ); + }; +}); + +jest.mock("./components/AuthProvider", () => ({ + useAuth: () => ({ user: null, loading: false, signOut: jest.fn() }), +})); + +describe("Privacy/Terms links and sign-in consent (hosted build)", () => { + it("renders Privacy and Terms in the footer", () => { + render(); + const footer = screen.getByRole("contentinfo"); + expect(within(footer).getByRole("link", { name: "Privacy" })).toHaveAttribute( + "href", + "/privacy", + ); + expect(within(footer).getByRole("link", { name: "Terms" })).toHaveAttribute( + "href", + "/terms", + ); + }); + + it("renders the sign-in consent line beneath the nav Sign in link with exact wording", () => { + render(); + const consent = screen.getByTestId("sign-in-consent"); + expect(consent).toHaveTextContent( + "By signing in you agree to the Terms and Privacy policy.", + ); + expect(within(consent).getByRole("link", { name: "Terms" })).toHaveAttribute( + "href", + "/terms", + ); + expect( + within(consent).getByRole("link", { name: "Privacy policy" }), + ).toHaveAttribute("href", "/privacy"); + }); +}); diff --git a/web/src/app/page.test.tsx b/web/src/app/page.test.tsx index b8c779fa6..3940e6562 100644 --- a/web/src/app/page.test.tsx +++ b/web/src/app/page.test.tsx @@ -330,6 +330,14 @@ describe("Navigation auth state", () => { expect(screen.queryByText("Go to Dashboard")).not.toBeInTheDocument(); }); + // No legal URLs configured in jest (self-host/staging default) — the + // consent line must not render at all. The hosted branch is covered in + // page.legal-links.hosted.test.tsx. + it("renders no sign-in consent line when no legal URLs are configured", () => { + render(); + expect(screen.queryByTestId("sign-in-consent")).not.toBeInTheDocument(); + }); + it("shows loading indicator while checking auth", () => { mockAuthValue = { user: null, loading: true, signOut: mockSignOut }; render(); @@ -376,6 +384,17 @@ describe("Footer", () => { expect(screen.getAllByText("Apache 2.0").length).toBeGreaterThan(0); }); + // Self-host and staging builds have no legal pages configured + // (lib/site.PRIVACY_URL / TERMS_URL are unset in jest, matching a build + // without NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL), so the footer + // must render neither link rather than pointing at a 404. The hosted + // branch is covered in page.legal-links.hosted.test.tsx. + it("renders no Privacy or Terms links when no legal URLs are configured", () => { + render(); + expect(screen.queryByRole("link", { name: "Privacy" })).toBeNull(); + expect(screen.queryByRole("link", { name: "Terms" })).toBeNull(); + }); + it("links the GitHub repo from the footer", () => { render(); const githubLink = screen diff --git a/web/src/app/page.tsx b/web/src/app/page.tsx index a713353f2..04acbbf03 100644 --- a/web/src/app/page.tsx +++ b/web/src/app/page.tsx @@ -7,7 +7,8 @@ import { Eyebrow } from "@e2a/ui"; import { JsonLd } from "./components/JsonLd"; import { TokenCanopyBadge } from "./components/loft/TokenCanopyBadge"; import { faqPage, type FaqEntry } from "../lib/jsonld"; -import { PRICING_PATH, SIGN_IN_URL } from "../lib/site"; +import { PRICING_PATH, SIGN_IN_URL, legalFooterLinks } from "../lib/site"; +import { SignInConsent } from "./components/SignInConsent"; // Agent onboarding surfaces: install the plugin in a coding agent, or point // any MCP runtime at the hosted server. Application integrations use the SDK @@ -133,6 +134,10 @@ const FOOTER_LINKS: { label: string; href: string; external?: boolean }[] = [ { label: "CLI", href: "https://www.npmjs.com/package/@e2a/cli", external: true }, { label: "Plugin", href: "https://github.com/tokencanopy/e2a/tree/main/plugins/e2a", external: true }, { label: "Feedback", href: "/feedback" }, + // Privacy/Terms are hosted-deployment-only — see PRIVACY_URL/TERMS_URL in + // lib/site. Each is present only when its URL is configured, so a + // self-host or staging footer renders exactly as it did before. + ...legalFooterLinks(), ]; // The landing page's answer surface. Every entry is rendered twice: as visible @@ -282,14 +287,25 @@ export default function Home() { already have an account; the primary CTA beside it starts a new one. Signed IN there is no such pair — both would just be doors into the app — so the primary becomes the only - one and points at the dashboard. */ - - Sign in - + one and points at the dashboard. + `relative` + the consent line's `absolute` positioning + (same pattern as NavMenu's dropdown below) keeps the + overlay out of the flex row's layout — it renders only on + the hosted build where both legal URLs are set, and must + not change nav height or wrap the other items. */ + )} { expect(site.SIGN_IN_LABEL).toBe("Sign in"); }); }); + +describe("site config — legal links", () => { + const PRIVACY_ENV_KEY = "NEXT_PUBLIC_PRIVACY_URL"; + const TERMS_ENV_KEY = "NEXT_PUBLIC_TERMS_URL"; + const originalPrivacy = process.env[PRIVACY_ENV_KEY]; + const originalTerms = process.env[TERMS_ENV_KEY]; + const originalSiteURL = process.env[SITE_URL_ENV_KEY]; + + afterEach(() => { + if (originalPrivacy === undefined) delete process.env[PRIVACY_ENV_KEY]; + else process.env[PRIVACY_ENV_KEY] = originalPrivacy; + if (originalTerms === undefined) delete process.env[TERMS_ENV_KEY]; + else process.env[TERMS_ENV_KEY] = originalTerms; + if (originalSiteURL === undefined) delete process.env[SITE_URL_ENV_KEY]; + else process.env[SITE_URL_ENV_KEY] = originalSiteURL; + }); + + it("defaults both URLs to empty and hides both footer links", () => { + delete process.env[PRIVACY_ENV_KEY]; + delete process.env[TERMS_ENV_KEY]; + const site = loadSite(); + expect(site.PRIVACY_URL).toBe(""); + expect(site.TERMS_URL).toBe(""); + expect(site.legalFooterLinks()).toEqual([]); + }); + + it("includes only Privacy when only the privacy URL is configured", () => { + process.env[PRIVACY_ENV_KEY] = "/privacy"; + delete process.env[TERMS_ENV_KEY]; + const site = loadSite(); + expect(site.legalFooterLinks()).toEqual([ + { label: "Privacy", href: "/privacy", external: false }, + ]); + }); + + it("includes only Terms when only the terms URL is configured", () => { + delete process.env[PRIVACY_ENV_KEY]; + process.env[TERMS_ENV_KEY] = "/terms"; + const site = loadSite(); + expect(site.legalFooterLinks()).toEqual([ + { label: "Terms", href: "/terms", external: false }, + ]); + }); + + it("includes Privacy then Terms, in that order, when both are configured", () => { + process.env[PRIVACY_ENV_KEY] = "/privacy"; + process.env[TERMS_ENV_KEY] = "/terms"; + const site = loadSite(); + expect(site.legalFooterLinks()).toEqual([ + { label: "Privacy", href: "/privacy", external: false }, + { label: "Terms", href: "/terms", external: false }, + ]); + }); + + it("treats a same-origin path as internal", () => { + delete process.env[SITE_URL_ENV_KEY]; + const site = loadSite(); + expect(site.isExternalURL("/privacy")).toBe(false); + }); + + it("treats an absolute URL on a different origin as external", () => { + process.env[SITE_URL_ENV_KEY] = "https://e2a.dev"; + process.env[PRIVACY_ENV_KEY] = "https://legal.example.com/privacy"; + delete process.env[TERMS_ENV_KEY]; + const site = loadSite(); + expect(site.legalFooterLinks()).toEqual([ + { + label: "Privacy", + href: "https://legal.example.com/privacy", + external: true, + }, + ]); + }); + + it("treats an absolute URL matching SITE_URL's own origin as internal", () => { + process.env[SITE_URL_ENV_KEY] = "https://e2a.dev"; + process.env[TERMS_ENV_KEY] = "https://e2a.dev/terms"; + delete process.env[PRIVACY_ENV_KEY]; + const site = loadSite(); + expect(site.legalFooterLinks()).toEqual([ + { label: "Terms", href: "https://e2a.dev/terms", external: false }, + ]); + }); + + it("treats malformed input as internal rather than throwing", () => { + delete process.env[SITE_URL_ENV_KEY]; + const site = loadSite(); + expect(site.isExternalURL("not a url")).toBe(false); + }); +}); diff --git a/web/src/lib/site.ts b/web/src/lib/site.ts index 680119ac4..2694f93d7 100644 --- a/web/src/lib/site.ts +++ b/web/src/lib/site.ts @@ -60,6 +60,57 @@ export const UMAMI_COLLECTOR_ORIGIN = process.env.NEXT_PUBLIC_UMAMI_COLLECTOR_OR // and listing a 404 in a sitemap is a crawl-quality problem, not a no-op. export const PRICING_PATH = process.env.NEXT_PUBLIC_PRICING_PATH || ""; +// Site-relative paths or absolute URLs of the hosted deployment's Privacy +// Policy and Terms of Service pages. +// +// Like pricing, these are NOT part of this app: the hosted deployment's +// legal pages live in the private ops repo and are baked in as +// NEXT_PUBLIC_PRIVACY_URL / NEXT_PUBLIC_TERMS_URL at image build time. A +// self-host or staging build that hasn't set them gets an empty string — +// every footer link and the sign-in consent line that reference these stay +// hidden entirely rather than pointing at a 404 or making a legal claim the +// deployment can't back up. Either value may be a same-origin path (e.g. +// "/privacy") or an absolute URL on a different host (e.g. the ops repo's +// own static route). +export const PRIVACY_URL = process.env.NEXT_PUBLIC_PRIVACY_URL || ""; +export const TERMS_URL = process.env.NEXT_PUBLIC_TERMS_URL || ""; + +// True when `url` is an absolute URL whose origin differs from this +// deployment's own SITE_URL — i.e. it needs target="_blank" + rel=noopener +// like the other external FOOTER_LINKS, rather than an in-app . A +// same-origin path (e.g. "/privacy") is always internal, and an absolute URL +// that happens to resolve to SITE_URL's own origin is treated the same way +// so a fully-qualified same-origin value behaves identically to a path. +// Malformed input (neither a path nor a parseable absolute URL) is treated +// as internal — the safer default for a value that's already misconfigured. +export function isExternalURL(url: string): boolean { + if (!url || url.startsWith("/")) return false; + try { + return new URL(url).origin !== new URL(SITE_URL).origin; + } catch { + return false; + } +} + +export type LegalLink = { label: string; href: string; external: boolean }; + +// Privacy/Terms entries for a public-page footer, in display order. Each +// entry is present only when its URL is configured — see PRIVACY_URL / +// TERMS_URL — so every footer that spreads this in renders nothing extra on +// a self-host or staging build. Centralized here so the landing page, blog, +// docs, and MCP footers all present the same pair rather than each growing +// its own copy that can drift. +export function legalFooterLinks(): LegalLink[] { + const links: LegalLink[] = []; + if (PRIVACY_URL) { + links.push({ label: "Privacy", href: PRIVACY_URL, external: isExternalURL(PRIVACY_URL) }); + } + if (TERMS_URL) { + links.push({ label: "Terms", href: TERMS_URL, external: isExternalURL(TERMS_URL) }); + } + return links; +} + // Sign-in entry point for the dashboard's "Sign in" links. Defaults to the // legacy Google OAuth door, which every self-host deployment has. The hosted // deployment bakes in NEXT_PUBLIC_E2A_SIGN_IN_URL=/api/auth/oidc/login at