Merge pull request #96 from Molecule-AI/feat/canvas-auth-redirect
feat(canvas): AuthGate — redirect anonymous users to cp login
This commit is contained in:
commit
96d88f42a6
@ -1,5 +1,6 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
import { AuthGate } from "@/components/AuthGate";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Molecule AI",
|
||||
@ -13,7 +14,13 @@ export default function RootLayout({
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="bg-zinc-950 text-white">{children}</body>
|
||||
<body className="bg-zinc-950 text-white">
|
||||
{/* AuthGate is a client component; it checks the session on mount
|
||||
and bounces anonymous users to the control plane's login page
|
||||
when running on a tenant subdomain. Non-SaaS hosts (localhost,
|
||||
vercel preview URL, apex) pass through unchanged. */}
|
||||
<AuthGate>{children}</AuthGate>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
68
canvas/src/components/AuthGate.tsx
Normal file
68
canvas/src/components/AuthGate.tsx
Normal file
@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* AuthGate wraps the canvas root so every page is gated on a valid session.
|
||||
* Anonymous users get bounced to app.moleculesai.app/cp/auth/login?return_to=<here>.
|
||||
*
|
||||
* In non-SaaS mode (no tenant slug — local dev, apex, vercel preview URL),
|
||||
* the gate is a pass-through: canvas works without auth for local dev.
|
||||
* This mirrors the control plane's "disabled provider" fallback.
|
||||
*/
|
||||
import { useEffect, useState, type ReactNode } from "react";
|
||||
import { fetchSession, redirectToLogin, type Session } from "@/lib/auth";
|
||||
import { getTenantSlug } from "@/lib/tenant";
|
||||
|
||||
export type AuthGateState =
|
||||
| { kind: "loading" }
|
||||
| { kind: "anonymous"; skipRedirect: boolean }
|
||||
| { kind: "authenticated"; session: Session };
|
||||
|
||||
export function AuthGate({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<AuthGateState>({ kind: "loading" });
|
||||
|
||||
useEffect(() => {
|
||||
// In non-SaaS mode (no tenant slug) we skip the gate entirely —
|
||||
// local dev, vercel preview URLs, and the app.moleculesai.app apex
|
||||
// should not force login for API-only interactions.
|
||||
const slug = getTenantSlug();
|
||||
if (!slug) {
|
||||
setState({ kind: "anonymous", skipRedirect: true });
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
fetchSession()
|
||||
.then((s) => {
|
||||
if (cancelled) return;
|
||||
if (s) {
|
||||
setState({ kind: "authenticated", session: s });
|
||||
} else {
|
||||
setState({ kind: "anonymous", skipRedirect: false });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Network error — fail closed (show signin) so a transient
|
||||
// outage doesn't leak the canvas UI to an unauth'd user.
|
||||
if (!cancelled) setState({ kind: "anonymous", skipRedirect: false });
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.kind === "anonymous" && !state.skipRedirect) {
|
||||
redirectToLogin("sign-in");
|
||||
}
|
||||
}, [state]);
|
||||
|
||||
if (state.kind === "loading") {
|
||||
// Minimal placeholder; canvas has its own loading UI downstream.
|
||||
return null;
|
||||
}
|
||||
if (state.kind === "anonymous" && !state.skipRedirect) {
|
||||
// Redirect already firing from the effect above; render nothing in
|
||||
// the interim to avoid a flash of unauthenticated content.
|
||||
return null;
|
||||
}
|
||||
return <>{children}</>;
|
||||
}
|
||||
69
canvas/src/lib/__tests__/auth.test.ts
Normal file
69
canvas/src/lib/__tests__/auth.test.ts
Normal file
@ -0,0 +1,69 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { fetchSession, redirectToLogin } from "../auth";
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("fetchSession", () => {
|
||||
it("returns session on 200", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ user_id: "u1", org_id: "o1", email: "a@x.com" }),
|
||||
}));
|
||||
const s = await fetchSession();
|
||||
expect(s).toEqual({ user_id: "u1", org_id: "o1", email: "a@x.com" });
|
||||
});
|
||||
|
||||
it("returns null on 401 without throwing", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 401 }));
|
||||
const s = await fetchSession();
|
||||
expect(s).toBeNull();
|
||||
});
|
||||
|
||||
it("throws on 500 so transient outages aren't treated as 'anonymous'", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 500, statusText: "oops" }));
|
||||
await expect(fetchSession()).rejects.toThrow("500");
|
||||
});
|
||||
|
||||
it("sends credentials:include for cross-origin cookies", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 401 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await fetchSession();
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining("/cp/auth/me"),
|
||||
expect.objectContaining({ credentials: "include" }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("redirectToLogin", () => {
|
||||
it("sets window.location to cp login URL with return_to", () => {
|
||||
const href = "https://acme.moleculesai.app/dashboard";
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { href },
|
||||
});
|
||||
redirectToLogin("sign-in");
|
||||
// href now holds the redirect target. encodeURIComponent(href) must
|
||||
// appear in the query.
|
||||
expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/login");
|
||||
expect((window.location as unknown as { href: string }).href).toContain(
|
||||
encodeURIComponent(href),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses signup path for sign-up screenHint", () => {
|
||||
Object.defineProperty(window, "location", {
|
||||
writable: true,
|
||||
value: { href: "https://acme.moleculesai.app/" },
|
||||
});
|
||||
redirectToLogin("sign-up");
|
||||
expect((window.location as unknown as { href: string }).href).toContain("/cp/auth/signup");
|
||||
});
|
||||
});
|
||||
51
canvas/src/lib/auth.ts
Normal file
51
canvas/src/lib/auth.ts
Normal file
@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Canvas-side session detection. Calls /cp/auth/me on the control plane
|
||||
* (via same-origin → PLATFORM_URL) and returns the session or null.
|
||||
*
|
||||
* 401 is the "anonymous" signal and does NOT throw — the caller decides
|
||||
* whether to redirect. Network errors do throw so React error boundaries
|
||||
* can surface them.
|
||||
*/
|
||||
import { PLATFORM_URL } from "./api";
|
||||
|
||||
export interface Session {
|
||||
user_id: string;
|
||||
org_id: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
// Base path prefix for auth endpoints on the control plane.
|
||||
const AUTH_BASE = "/cp/auth";
|
||||
|
||||
/**
|
||||
* fetchSession probes /cp/auth/me with the session cookie (credentials:
|
||||
* include mandatory cross-origin). Returns the Session on 200, null on
|
||||
* 401 (anonymous), throws on anything else so callers don't silently
|
||||
* treat a 5xx as "not logged in".
|
||||
*/
|
||||
export async function fetchSession(): Promise<Session | null> {
|
||||
const res = await fetch(`${PLATFORM_URL}${AUTH_BASE}/me`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (res.status === 401) return null;
|
||||
if (!res.ok) {
|
||||
throw new Error(`/cp/auth/me: ${res.status} ${res.statusText}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* redirectToLogin bounces the browser to the control plane's login page
|
||||
* with a `return_to` param so the user lands back on the current URL
|
||||
* after signup/login completes. Same-origin safety is enforced on the
|
||||
* CP side (isSafeReturnTo rejects cross-domain / http / protocol-
|
||||
* relative URLs). Uses window.location.href so the full URL including
|
||||
* query + hash survives the round trip.
|
||||
*/
|
||||
export function redirectToLogin(screenHint: "sign-up" | "sign-in" = "sign-in"): void {
|
||||
if (typeof window === "undefined") return;
|
||||
const returnTo = window.location.href;
|
||||
const path = screenHint === "sign-up" ? "signup" : "login";
|
||||
const dest = `${PLATFORM_URL}${AUTH_BASE}/${path}?return_to=${encodeURIComponent(returnTo)}`;
|
||||
window.location.href = dest;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user