feat(canvas): ToS gate modal + us-east-2 data residency notice
Wraps /orgs in a TermsGate that polls /cp/auth/terms-status on mount and overlays a blocking modal when the current terms version hasn't been accepted yet. "I agree" POSTs /cp/auth/accept-terms and dismisses the modal; the backend records IP + UA as GDPR Art. 7 proof-of-consent. Also adds a short data residency notice under the page header: workspaces run in AWS us-east-2 (Ohio, US). An EU region selector is a future lift once the infra is provisioned there. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
adf43c50d8
commit
dd5654b803
@ -21,6 +21,7 @@ import { useEffect, useState } from "react";
|
||||
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
|
||||
import { PLATFORM_URL } from "@/lib/api";
|
||||
import { formatCredits, pillTone, bannerKind } from "@/lib/credits";
|
||||
import { TermsGate } from "@/components/TermsGate";
|
||||
|
||||
type OrgStatus = "awaiting_payment" | "provisioning" | "running" | "failed" | string;
|
||||
|
||||
@ -162,17 +163,38 @@ function CheckoutBanner() {
|
||||
function Shell({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<main className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<div className="mx-auto max-w-2xl px-6 pt-20 pb-12">
|
||||
<h1 className="text-3xl font-bold text-white">Your organizations</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
Each org is an isolated Molecule workspace.
|
||||
</p>
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
<TermsGate>
|
||||
<div className="mx-auto max-w-2xl px-6 pt-20 pb-12">
|
||||
<h1 className="text-3xl font-bold text-white">Your organizations</h1>
|
||||
<p className="mt-2 text-zinc-400">
|
||||
Each org is an isolated Molecule workspace.
|
||||
</p>
|
||||
<DataResidencyNotice />
|
||||
<div className="mt-8">{children}</div>
|
||||
</div>
|
||||
</TermsGate>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// DataResidencyNotice surfaces where workspace data lives so EU-based
|
||||
// signups can make an informed choice (GDPR Art. 13 disclosure
|
||||
// requirement). Plain text, no icon — the goal is clarity, not
|
||||
// decoration. A future EU region selector can replace this with a
|
||||
// region dropdown.
|
||||
function DataResidencyNotice() {
|
||||
return (
|
||||
<p className="mt-3 rounded border border-zinc-800 bg-zinc-900/60 px-3 py-2 text-xs text-zinc-400">
|
||||
Workspaces run in AWS us-east-2 (Ohio, United States). EU region support is on the roadmap — reach out to
|
||||
{" "}
|
||||
<a href="mailto:support@moleculesai.app" className="underline">
|
||||
support@moleculesai.app
|
||||
</a>
|
||||
{" "}if you need data residency in another region today.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
function OrgRow({ org }: { org: Org }) {
|
||||
return (
|
||||
<li className="rounded-lg border border-zinc-800 bg-zinc-900 p-4">
|
||||
|
||||
117
canvas/src/components/TermsGate.tsx
Normal file
117
canvas/src/components/TermsGate.tsx
Normal file
@ -0,0 +1,117 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { PLATFORM_URL } from "@/lib/api";
|
||||
|
||||
// TermsGate blocks the page it wraps until the user has accepted the
|
||||
// current terms version. Fetches /cp/auth/terms-status on mount; if
|
||||
// the server says accepted=false it renders a modal over the children
|
||||
// instead of hiding them entirely — that way the /orgs list is still
|
||||
// visible behind the gate so the user understands what they're
|
||||
// agreeing to touch.
|
||||
//
|
||||
// The server is the source of truth; this component is a UX
|
||||
// convenience. Org-mutating endpoints should (and do) also enforce
|
||||
// ToS via their own DB check so a power-user calling curl can't
|
||||
// bypass the gate.
|
||||
export function TermsGate({ children }: { children: React.ReactNode }) {
|
||||
const [status, setStatus] = useState<"loading" | "accepted" | "pending" | "error">("loading");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
const res = await fetch(`${PLATFORM_URL}/cp/auth/terms-status`, {
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (cancelled) return;
|
||||
if (res.status === 401) {
|
||||
// Not signed in — the page this wraps handles redirect to login.
|
||||
// Fall through to "accepted" so we don't double-gate anonymous.
|
||||
setStatus("accepted");
|
||||
return;
|
||||
}
|
||||
if (!res.ok) {
|
||||
setStatus("error");
|
||||
setError(`terms-status: ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const body = (await res.json()) as { accepted?: boolean };
|
||||
setStatus(body.accepted ? "accepted" : "pending");
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
setStatus("error");
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
const accept = async () => {
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`${PLATFORM_URL}/cp/auth/accept-terms`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const text = await res.text();
|
||||
throw new Error(`${res.status}: ${text}`);
|
||||
}
|
||||
setStatus("accepted");
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
{status === "pending" && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div className="mx-4 max-w-lg rounded-lg border border-zinc-700 bg-zinc-900 p-6 shadow-xl">
|
||||
<h2 className="text-lg font-semibold text-white">Terms & conditions</h2>
|
||||
<p className="mt-3 text-sm text-zinc-300">
|
||||
Before you create an organization, please review our{" "}
|
||||
<a href="/legal/terms" className="text-sky-400 underline" target="_blank" rel="noreferrer">
|
||||
Terms of Service
|
||||
</a>{" "}
|
||||
and{" "}
|
||||
<a href="/legal/privacy" className="text-sky-400 underline" target="_blank" rel="noreferrer">
|
||||
Privacy Policy
|
||||
</a>
|
||||
. Click agree to continue.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-zinc-500">
|
||||
By agreeing you acknowledge that workspace data is stored in AWS us-east-2 (Ohio, United States).
|
||||
</p>
|
||||
{error && <p className="mt-3 text-sm text-red-400">{error}</p>}
|
||||
<div className="mt-5 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={accept}
|
||||
disabled={submitting}
|
||||
className="rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
>
|
||||
{submitting ? "Saving…" : "I agree"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{status === "error" && (
|
||||
<div className="fixed bottom-4 left-4 right-4 mx-auto max-w-md rounded border border-red-800 bg-red-950 p-3 text-sm text-red-200">
|
||||
Couldn't check terms status: {error ?? "unknown error"}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user