"use client"; /* Login / register screen + AuthGate. AuthGate calls /v1/auth/me on mount: in the default (auth-not-required) mode the backend returns the seeded owner, so the gate passes straight through and the console works as before. When FORGE_AUTH_REQUIRED=true, an unauthenticated /me returns 401 and the gate shows this screen. */ import { ReactNode, useCallback, useEffect, useState } from "react"; import { api, clearTokens, setTokens, UNAUTHORIZED_EVENT } from "@/lib/api"; import type { MeResult, MyConnection, Project } from "@/lib/api"; function AcceptInviteScreen({ token, onAuthed, onCancel }: { token: string; onAuthed: () => void; onCancel: () => void }) { const [info, setInfo] = useState<{ email: string; role: string } | null>(null); const [loadErr, setLoadErr] = useState(null); const [password, setPassword] = useState(""); const [confirm, setConfirm] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); useEffect(() => { api.inviteInfo(token) .then(setInfo) .catch(() => setLoadErr("This invite link is invalid or has expired. Ask an admin to send a new one.")); }, [token]); async function submit(e: React.FormEvent) { e.preventDefault(); if (password !== confirm) { setError("Passwords don't match."); return; } setBusy(true); setError(null); try { const res = await api.acceptInvite(token, password); setTokens(res.access_token, res.refresh_token); onAuthed(); } catch { setError("Could not set your password. The link may have expired (minimum 8 characters)."); } finally { setBusy(false); } } return (
Forge
{loadErr ? ( <>
{loadErr}
) : ( <>
{info ? <>Set a password for {info.email} to join as a {info.role}. : "Loading your invite…"}
{error &&
{error}
} )}
); } function LoginScreen({ onAuthed }: { onAuthed: () => void }) { const [mode, setMode] = useState<"login" | "register">("login"); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [workspace, setWorkspace] = useState(""); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); async function submit(e: React.FormEvent) { e.preventDefault(); setBusy(true); setError(null); try { const res = mode === "login" ? await api.login(email.trim(), password) : await api.register(email.trim(), password, workspace.trim() || undefined); setTokens(res.access_token, res.refresh_token); onAuthed(); } catch (err: any) { setError(mode === "login" ? "Invalid email or password." : "Could not create the account (email may already exist, or password is too short)."); } finally { setBusy(false); } } return (
Forge
{mode === "login" ? "Sign in to your workspace" : "Create your workspace"}
{mode === "register" && ( )} {error &&
{error}
}
); } export function AuthGate({ children }: { children: ReactNode }) { const [state, setState] = useState<"loading" | "authed" | "login">("loading"); const [me, setMe] = useState(null); const [invite, setInvite] = useState(null); // An invite link (?invite=) takes over the gate so a new teammate can set their // password even if there's a stale session in this browser. const clearInviteParam = useCallback(() => { if (typeof window !== "undefined") window.history.replaceState({}, "", window.location.pathname); setInvite(null); }, []); useEffect(() => { if (typeof window === "undefined") return; const t = new URLSearchParams(window.location.search).get("invite"); if (t) setInvite(t); }, []); const check = useCallback(() => { api.me() .then((m) => { setMe(m); setState("authed"); }) .catch((e: any) => { // Fail OPEN: only an explicit 401 means auth is enforced and we must log in. // A 404/network error (e.g. an older backend without /auth/me, or auth disabled) // must NOT lock the user out of the console. const is401 = typeof e?.message === "string" && e.message.startsWith("401"); setState(is401 ? "login" : "authed"); }); }, []); useEffect(() => { check(); }, [check]); useEffect(() => { const h = () => setState("login"); window.addEventListener(UNAUTHORIZED_EVENT, h); return () => window.removeEventListener(UNAUTHORIZED_EVENT, h); }, []); if (invite) return { clearInviteParam(); check(); }} onCancel={() => { clearInviteParam(); setState("login"); }} />; if (state === "loading") return
Loading…
; if (state === "login") return check()} />; // MCP-only users (connector role) get just their token page, not the full console. if (me?.role === "connector") return ; return <>{children}; } /* Minimal console for an MCP-only (connector) user: pick a project, generate a personal access token, copy the endpoint, and connect any per-user credentials the project needs - no projects/tools/settings management. */ function ConnectorHome({ me }: { me: MeResult }) { const [projects, setProjects] = useState([]); useEffect(() => { api.listProjects().then(setProjects).catch(() => {}); }, []); return (
Your MCP access
Signed in as {me.email}. Generate a personal token for a project and paste it into your MCP client (Claude, Cursor, …) as Authorization: Bearer <token>. If a project needs you to connect your own accounts, set those below too.
{projects.length === 0 &&
No projects available yet.
}
{projects.map((p) => )}
); } /* One project card on the connector home: MCP endpoint + PAT generation + any per-user ("external") credentials this project requires the user to connect for on-behalf-of tool calls. */ function ProjectConnector({ project }: { project: Project }) { const [token, setToken] = useState(""); const [aps, setAps] = useState([]); const origin = typeof window !== "undefined" ? window.location.origin : ""; const box: React.CSSProperties = { background: "var(--bg-2)", padding: 8, borderRadius: 6, overflowX: "auto", margin: "4px 0" }; useEffect(() => { api.listMyConnections(project.id).then(setAps).catch(() => {}); }, [project.id]); async function gen() { const t = await api.createMcpToken(project.id, {}); setToken(t.token || ""); } return (
{project.name}
MCP endpoint
{`${origin}/api/forge/v1/mcp/${project.id}`}
{token ? ( <>
Access token — copy now, shown once:
{token}
) : ( )} {aps.length > 0 && (
Connect your accounts
These tools call downstream systems as you. Paste your own token for each — stored per-user and encrypted, used only for your calls.
{aps.map((ap) => )}
)}
); } /* The current user's own downstream token for one per-user provider (see auth.tsx PerUserConnect / deploy.tsx MyConnectionCard - same 3 endpoints). Keyed server-side by the caller's user id. */ function MyConnectionRow({ project, ap }: { project: Project; ap: MyConnection }) { const [status, setStatus] = useState<{ connected: boolean } | null>(null); const [token, setToken] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const refresh = useCallback(() => { api.getMyConnection(project.id, ap.id).then(setStatus).catch(() => setStatus(null)); }, [project.id, ap.id]); useEffect(() => { refresh(); }, [refresh]); async function save() { setErr(null); setBusy(true); try { const res = await api.setMyConnection(project.id, ap.id, token.trim()); if (!res.ok) throw new Error("Could not save token."); setToken(""); refresh(); } catch (e: any) { setErr(e?.message || String(e)); } finally { setBusy(false); } } async function clear() { try { await api.clearMyConnection(project.id, ap.id); } catch { /* best-effort */ } refresh(); } return (
{ap.name} {status?.connected ? "connected" : "not connected"}
{status?.connected && }
setToken(e.target.value)} placeholder="paste your token…" style={{ flex: 1 }} />
{err &&
{err}
}
); }