Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<head>` 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

Expand Down
10 changes: 10 additions & 0 deletions web/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
5 changes: 5 additions & 0 deletions web/src/app/(app)/AppLayoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -141,6 +142,10 @@ export default function AppLayout({
secondaryClassName="text-[13px] underline underline-offset-2"
secondaryStyle={{ color: "var(--fg-muted)" }}
/>
<SignInConsent
className="text-[12px]"
style={{ color: "var(--fg-subtle)" }}
/>
</div>
</div>
</div>
Expand Down
68 changes: 68 additions & 0 deletions web/src/app/(app)/layout.legal-consent.hosted.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a href={href} {...rest}>
{children}
</a>
);
};
});

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(
<AppLayout>
<div>page content</div>
</AppLayout>,
);
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");
});
});
3 changes: 3 additions & 0 deletions web/src/app/(app)/layout.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
5 changes: 5 additions & 0 deletions web/src/app/blog/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 =
Expand Down Expand Up @@ -90,6 +91,10 @@ export default function BlogLayout({ children }: { children: React.ReactNode })
>
e2a
</span>
<LegalFooterLinks
className="flex gap-3 text-[12px]"
linkStyle={{ color: "var(--fg-muted)" }}
/>
<span
className="font-mono text-[12px]"
style={{ color: "var(--fg-subtle)" }}
Expand Down
75 changes: 75 additions & 0 deletions web/src/app/components/LegalFooterLinks.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
// LegalFooterLinks 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 } 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 (
<a href={href} {...props}>
{children}
</a>
);
};
});

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(<LegalFooterLinks />);
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(<LegalFooterLinks />);
expect(screen.getByRole("link", { name: "Privacy" })).toHaveAttribute(
"href",
"/privacy",
);
expect(screen.getByRole("link", { name: "Terms" })).toHaveAttribute(
"href",
"/terms",
);
});
});
39 changes: 39 additions & 0 deletions web/src/app/components/LegalFooterLinks.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className={className}>
{links.map((l) =>
l.external ? (
<a
key={l.label}
href={l.href}
target="_blank"
rel="noopener noreferrer"
style={linkStyle}
>
{l.label}
</a>
) : (
<Link key={l.label} href={l.href} style={linkStyle}>
{l.label}
</Link>
),
)}
</div>
);
}
124 changes: 124 additions & 0 deletions web/src/app/components/SignInConsent.test.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<a href={href} {...props}>
{children}
</a>
);
};
});

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(<SignInConsent />);
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(<SignInConsent />);
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(<SignInConsent />);
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(<SignInConsent />);
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(<SignInConsent />);
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(<SignInConsent />);
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");
});
});
Loading
Loading