diff --git a/server/src/__tests__/adapter-routes.test.ts b/server/src/__tests__/adapter-routes.test.ts index c1ce6c3abd3..4d5b9100d94 100644 --- a/server/src/__tests__/adapter-routes.test.ts +++ b/server/src/__tests__/adapter-routes.test.ts @@ -28,7 +28,7 @@ const overridingConfigSchemaAdapter: ServerAdapterModule = { }), }; -function createApp() { +function createApp(actorOverrides: Partial = {}) { const app = express(); app.use(express.json()); app.use((req, _res, next) => { @@ -38,6 +38,7 @@ function createApp() { companyIds: [], source: "local_implicit", isInstanceAdmin: false, + ...actorOverrides, }; next(); }); @@ -75,4 +76,18 @@ describe("adapter routes", () => { expect(builtin.status, JSON.stringify(builtin.body)).toBe(404); expect(String(builtin.body.error ?? "")).toContain("does not provide a config schema"); }); + + it("rejects signed-in users without org access", async () => { + const app = createApp({ + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }); + + const res = await request(app).get("/api/adapters/claude_local/config-schema"); + + expect(res.status, JSON.stringify(res.body)).toBe(403); + }); }); diff --git a/server/src/__tests__/authz-company-access.test.ts b/server/src/__tests__/authz-company-access.test.ts index 37850c306ee..0928d62bbd2 100644 --- a/server/src/__tests__/authz-company-access.test.ts +++ b/server/src/__tests__/authz-company-access.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { assertCompanyAccess } from "../routes/authz.js"; +import { assertBoardOrgAccess, assertCompanyAccess, hasBoardOrgAccess } from "../routes/authz.js"; function makeReq(input: { method?: string; @@ -76,3 +76,53 @@ describe("assertCompanyAccess", () => { expect(() => assertCompanyAccess(req, "company-1")).not.toThrow(); }); }); + +describe("assertBoardOrgAccess", () => { + it("allows signed-in board users with active company access", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "user-1", + source: "session", + companyIds: ["company-1"], + memberships: [{ companyId: "company-1", membershipRole: "operator", status: "active" }], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("allows instance admins without company memberships", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "admin-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: true, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(true); + expect(() => assertBoardOrgAccess(req)).not.toThrow(); + }); + + it("rejects signed-in users without company access or instance admin rights", () => { + const req = makeReq({ + actor: { + type: "board", + userId: "outsider-1", + source: "session", + companyIds: [], + memberships: [], + isInstanceAdmin: false, + }, + }); + + expect(hasBoardOrgAccess(req)).toBe(false); + expect(() => assertBoardOrgAccess(req)).toThrow("Company membership or instance admin access required"); + }); +}); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 12f8ff7af3d..2688b66867f 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -155,6 +155,22 @@ describe("instance settings routes", () => { expect(mockInstanceSettingsService.getGeneral).toHaveBeenCalled(); }); + it("rejects signed-in users without company access from reading general settings", async () => { + const app = await createApp({ + type: "board", + userId: "user-2", + source: "session", + isInstanceAdmin: false, + companyIds: [], + memberships: [], + }); + + const res = await request(app).get("/api/instance/settings/general"); + + expect(res.status).toBe(403); + expect(mockInstanceSettingsService.getGeneral).not.toHaveBeenCalled(); + }); + it("rejects non-admin board users from updating general settings", async () => { const app = await createApp({ type: "board", diff --git a/server/src/routes/adapters.ts b/server/src/routes/adapters.ts index 27e32a062a1..221e54989c0 100644 --- a/server/src/routes/adapters.ts +++ b/server/src/routes/adapters.ts @@ -41,7 +41,7 @@ import type { AdapterPluginRecord } from "../services/adapter-plugin-store.js"; import type { ServerAdapterModule, AdapterConfigSchema } from "../adapters/types.js"; import { loadExternalAdapterPackage, getUiParserSource, getOrExtractUiParserSource, reloadExternalAdapter } from "../adapters/plugin-loader.js"; import { logger } from "../middleware/logger.js"; -import { assertBoard } from "./authz.js"; +import { assertBoardOrgAccess } from "./authz.js"; import { BUILTIN_ADAPTER_TYPES } from "../adapters/builtin-adapter-types.js"; const execFileAsync = promisify(execFile); @@ -174,7 +174,7 @@ export function adapterRoutes() { * its model count, and load status. */ router.get("/adapters", async (_req, res) => { - assertBoard(_req); + assertBoardOrgAccess(_req); const registeredAdapters = listServerAdapters(); const externalRecords = new Map( @@ -200,7 +200,7 @@ export function adapterRoutes() { * - version?: string — target version for npm packages */ router.post("/adapters/install", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { packageName, isLocalPath = false, version } = req.body as AdapterInstallRequest; @@ -332,7 +332,7 @@ export function adapterRoutes() { * Request body: { "disabled": boolean } */ router.patch("/adapters/:type", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; const { disabled } = req.body as { disabled?: boolean }; @@ -367,7 +367,7 @@ export function adapterRoutes() { * keep the adapter they started with. */ router.patch("/adapters/:type/override", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; const { paused } = req.body as { paused?: boolean }; @@ -395,7 +395,7 @@ export function adapterRoutes() { * Unregister an external adapter. Built-in adapters cannot be removed. */ router.delete("/adapters/:type", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const adapterType = req.params.type; @@ -470,7 +470,7 @@ export function adapterRoutes() { * Cannot be used on built-in adapter types. */ router.post("/adapters/:type/reload", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const type = req.params.type; @@ -522,7 +522,7 @@ export function adapterRoutes() { // This is a convenience shortcut for remove + install with the same // package name, but without the risk of losing the store record. router.post("/adapters/:type/reinstall", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const type = req.params.type; @@ -591,7 +591,7 @@ export function adapterRoutes() { const CONFIG_SCHEMA_TTL_MS = 30_000; router.get("/adapters/:type/config-schema", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { type } = req.params; const adapter = findActiveServerAdapter(type); @@ -629,7 +629,7 @@ export function adapterRoutes() { // The adapter package must export a "./ui-parser" entry in package.json // pointing to a self-contained ESM module with zero runtime dependencies. router.get("/adapters/:type/ui-parser.js", (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { type } = req.params; const source = getOrExtractUiParserSource(type); if (!source) { diff --git a/server/src/routes/authz.ts b/server/src/routes/authz.ts index 6bf519d8f33..b6b639b67a8 100644 --- a/server/src/routes/authz.ts +++ b/server/src/routes/authz.ts @@ -7,6 +7,24 @@ export function assertBoard(req: Request) { } } +export function hasBoardOrgAccess(req: Request) { + if (req.actor.type !== "board") { + return false; + } + if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { + return true; + } + return Array.isArray(req.actor.companyIds) && req.actor.companyIds.length > 0; +} + +export function assertBoardOrgAccess(req: Request) { + assertBoard(req); + if (hasBoardOrgAccess(req)) { + return; + } + throw forbidden("Company membership or instance admin access required"); +} + export function assertInstanceAdmin(req: Request) { assertBoard(req); if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin) { diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 7bd24e36a3b..946278ef4e9 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -4,7 +4,7 @@ import { patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSc import { forbidden } from "../errors.js"; import { validate } from "../middleware/validate.js"; import { instanceSettingsService, logActivity } from "../services/index.js"; -import { getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, getActorInfo } from "./authz.js"; function assertCanManageInstanceSettings(req: Request) { if (req.actor.type !== "board") { @@ -22,10 +22,8 @@ export function instanceSettingsRoutes(db: Db) { router.get("/instance/settings/general", async (req, res) => { // General settings (e.g. keyboardShortcuts) are readable by any - // authenticated board user. Only PATCH requires instance-admin. - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + // authenticated org member or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getGeneral()); }); @@ -60,11 +58,9 @@ export function instanceSettingsRoutes(db: Db) { ); router.get("/instance/settings/experimental", async (req, res) => { - // Experimental settings are readable by any authenticated board user. - // Only PATCH requires instance-admin. - if (req.actor.type !== "board") { - throw forbidden("Board access required"); - } + // Experimental settings are readable by any authenticated org member + // or instance admin. Only PATCH requires instance-admin. + assertBoardOrgAccess(req); res.json(await svc.getExperimental()); }); diff --git a/server/src/routes/plugins.ts b/server/src/routes/plugins.ts index 95215d899af..582e699a4f3 100644 --- a/server/src/routes/plugins.ts +++ b/server/src/routes/plugins.ts @@ -47,7 +47,7 @@ import type { PluginStreamBus } from "../services/plugin-stream-bus.js"; import type { PluginToolDispatcher } from "../services/plugin-tool-dispatcher.js"; import type { ToolRunContext } from "@paperclipai/plugin-sdk"; import { JsonRpcCallError, PLUGIN_RPC_ERROR_CODES } from "@paperclipai/plugin-sdk"; -import { assertBoard, assertCompanyAccess, getActorInfo } from "./authz.js"; +import { assertBoardOrgAccess, assertCompanyAccess, getActorInfo } from "./authz.js"; import { validateInstanceConfig } from "../services/plugin-config-validator.js"; /** UI slot declaration extracted from plugin manifest */ @@ -371,7 +371,7 @@ export function pluginRoutes( * Response: `PluginRecord[]` */ router.get("/plugins", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const rawStatus = req.query.status; if (rawStatus !== undefined) { if (typeof rawStatus !== "string" || !(PLUGIN_STATUSES as readonly string[]).includes(rawStatus)) { @@ -395,7 +395,7 @@ export function pluginRoutes( * These can be installed through the normal local-path install flow. */ router.get("/plugins/examples", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); res.json(listBundledPluginExamples()); }); @@ -440,7 +440,7 @@ export function pluginRoutes( * Response: PluginUiContribution[] */ router.get("/plugins/ui-contributions", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const plugins = await registry.listByStatus("ready"); const contributions: PluginUiContribution[] = plugins @@ -483,7 +483,7 @@ export function pluginRoutes( * Errors: 501 if tool dispatcher is not configured */ router.get("/plugins/tools", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -517,7 +517,7 @@ export function pluginRoutes( * - 502 if the plugin worker is unavailable or the RPC call fails */ router.post("/plugins/tools/execute", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!toolDeps) { res.status(501).json({ error: "Plugin tool dispatch is not enabled" }); @@ -601,7 +601,7 @@ export function pluginRoutes( * - `500` — installation succeeded but manifest is missing (indicates a loader bug) */ router.post("/plugins/install", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { packageName, version, isLocalPath } = req.body as PluginInstallRequest; // Input validation @@ -793,7 +793,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/data", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -876,7 +876,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/bridge/action", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -960,7 +960,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/data/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1039,7 +1039,7 @@ export function pluginRoutes( * @see PLUGIN_SPEC.md §19.7 — Error Propagation Through The Bridge */ router.post("/plugins/:pluginId/actions/:key", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1120,7 +1120,7 @@ export function pluginRoutes( * - 501 if bridge deps or stream bus are not configured */ router.get("/plugins/:pluginId/bridge/stream/:channel", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps?.streamBus) { res.status(501).json({ error: "Plugin stream bridge is not enabled" }); @@ -1198,7 +1198,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); if (!plugin) { @@ -1228,7 +1228,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.delete("/plugins/:pluginId", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const purge = req.query.purge === "true"; @@ -1264,7 +1264,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/enable", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1302,7 +1302,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/disable", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const body = req.body as { reason?: string } | undefined; const reason = body?.reason; @@ -1343,7 +1343,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/health", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1411,7 +1411,7 @@ export function pluginRoutes( * Response: Array of log entries, newest first. */ router.get("/plugins/:pluginId/logs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1461,7 +1461,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found, 400 for lifecycle errors */ router.post("/plugins/:pluginId/upgrade", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const body = req.body as { version?: string } | undefined; const version = body?.version; @@ -1510,7 +1510,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1540,7 +1540,7 @@ export function pluginRoutes( * - 404 if plugin not found */ router.post("/plugins/:pluginId/config", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); @@ -1645,7 +1645,7 @@ export function pluginRoutes( * - 502 if the worker is unavailable */ router.post("/plugins/:pluginId/config/test", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!bridgeDeps) { res.status(501).json({ error: "Plugin bridge is not enabled" }); @@ -1742,7 +1742,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1788,7 +1788,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/jobs/:jobId/runs", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -1836,7 +1836,7 @@ export function pluginRoutes( * - 400 if job not found, not active, already running, or worker unavailable */ router.post("/plugins/:pluginId/jobs/:jobId/trigger", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); if (!jobDeps) { res.status(501).json({ error: "Job scheduling is not enabled" }); return; @@ -2042,7 +2042,7 @@ export function pluginRoutes( * Errors: 404 if plugin not found */ router.get("/plugins/:pluginId/dashboard", async (req, res) => { - assertBoard(req); + assertBoardOrgAccess(req); const { pluginId } = req.params; const plugin = await resolvePlugin(registry, pluginId); diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx new file mode 100644 index 00000000000..bec9c199ae2 --- /dev/null +++ b/ui/src/App.test.tsx @@ -0,0 +1,146 @@ +// @vitest-environment jsdom + +import { act, type ReactNode } from "react"; +import { createRoot } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { CloudAccessGate } from "./components/CloudAccessGate"; + +const mockHealthApi = vi.hoisted(() => ({ + get: vi.fn(), +})); + +const mockAuthApi = vi.hoisted(() => ({ + getSession: vi.fn(), +})); + +const mockAccessApi = vi.hoisted(() => ({ + getCurrentBoardAccess: vi.fn(), +})); + +vi.mock("./api/health", () => ({ + healthApi: mockHealthApi, +})); + +vi.mock("./api/auth", () => ({ + authApi: mockAuthApi, +})); + +vi.mock("./api/access", () => ({ + accessApi: mockAccessApi, +})); + +vi.mock("@/lib/router", () => ({ + Navigate: ({ to }: { to: string }) =>
Navigate:{to}
, + Outlet: () =>
Outlet content
, + Route: ({ children }: { children?: ReactNode }) => <>{children}, + Routes: ({ children }: { children?: ReactNode }) => <>{children}, + useLocation: () => ({ pathname: "/instance/settings/general", search: "", hash: "" }), + useParams: () => ({}), +})); + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +async function flushReact() { + await act(async () => { + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); + }); +} + +describe("CloudAccessGate", () => { + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + bootstrapStatus: "ready", + }); + }); + + afterEach(() => { + container.remove(); + document.body.innerHTML = ""; + vi.clearAllMocks(); + }); + + it("shows a no-access message for signed-in users without org access", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: [], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("No company access"); + expect(container.textContent).not.toContain("Outlet content"); + + await act(async () => { + root.unmount(); + }); + }); + + it("allows authenticated users with company access through to the board", async () => { + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + userId: "user-1", + isInstanceAdmin: false, + companyIds: ["company-1"], + source: "session", + keyId: null, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + await flushReact(); + await flushReact(); + + expect(container.textContent).toContain("Outlet content"); + expect(container.textContent).not.toContain("No company access"); + + await act(async () => { + root.unmount(); + }); + }); +}); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index de51ab3f606..894b86b750b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,10 +1,8 @@ import { Navigate, Outlet, Route, Routes, useLocation, useParams } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; import { Button } from "@/components/ui/button"; import { Layout } from "./components/Layout"; import { OnboardingWizard } from "./components/OnboardingWizard"; -import { authApi } from "./api/auth"; -import { healthApi } from "./api/health"; +import { CloudAccessGate } from "./components/CloudAccessGate"; import { Dashboard } from "./pages/Dashboard"; import { Companies } from "./pages/Companies"; import { Agents } from "./pages/Agents"; @@ -50,79 +48,11 @@ import { CliAuthPage } from "./pages/CliAuth"; import { InviteLandingPage } from "./pages/InviteLanding"; import { JoinRequestQueue } from "./pages/JoinRequestQueue"; import { NotFoundPage } from "./pages/NotFound"; -import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; import { loadLastInboxTab } from "./lib/inbox"; import { shouldRedirectCompanylessRouteToOnboarding } from "./lib/onboarding-route"; -function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { - return ( -
-
-

Instance setup required

-

- {hasActiveInvite - ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" - : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"} -

-
-{`pnpm paperclipai auth bootstrap-ceo`}
-        
-
-
- ); -} - -function CloudAccessGate() { - const location = useLocation(); - const healthQuery = useQuery({ - queryKey: queryKeys.health, - queryFn: () => healthApi.get(), - retry: false, - refetchInterval: (query) => { - const data = query.state.data as - | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } - | undefined; - return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" - ? 2000 - : false; - }, - refetchIntervalInBackground: true, - }); - - const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; - const sessionQuery = useQuery({ - queryKey: queryKeys.auth.session, - queryFn: () => authApi.getSession(), - enabled: isAuthenticatedMode, - retry: false, - }); - - if (healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading)) { - return
Loading...
; - } - - if (healthQuery.error) { - return ( -
- {healthQuery.error instanceof Error ? healthQuery.error.message : "Failed to load app state"} -
- ); - } - - if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { - return ; - } - - if (isAuthenticatedMode && !sessionQuery.data) { - const next = encodeURIComponent(`${location.pathname}${location.search}`); - return ; - } - - return ; -} - function boardRoutes() { return ( <> diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index 90add6b3906..dce8fe6dbc5 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -207,6 +207,15 @@ export type UserCompanyAccessResponse = { companyAccess: UserCompanyAccessEntry[]; }; +export type CurrentBoardAccess = { + user: { id: string; email: string | null; name: string | null; image: string | null } | null; + userId: string; + isInstanceAdmin: boolean; + companyIds: string[]; + source: string; + keyId: string | null; +}; + export const accessApi = { createCompanyInvite: ( companyId: string, @@ -326,4 +335,7 @@ export const accessApi = { setUserCompanyAccess: (userId: string, companyIds: string[]) => api.put(`/admin/users/${userId}/company-access`, { companyIds }), + + getCurrentBoardAccess: () => + api.get("/cli-auth/me"), }; diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx new file mode 100644 index 00000000000..eaea736bcdf --- /dev/null +++ b/ui/src/components/CloudAccessGate.tsx @@ -0,0 +1,114 @@ +import { Navigate, Outlet, useLocation } from "@/lib/router"; +import { useQuery } from "@tanstack/react-query"; +import { accessApi } from "@/api/access"; +import { authApi } from "@/api/auth"; +import { healthApi } from "@/api/health"; +import { queryKeys } from "@/lib/queryKeys"; + +function BootstrapPendingPage({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { + return ( +
+
+

Instance setup required

+

+ {hasActiveInvite + ? "No instance admin exists yet. A bootstrap invite is already active. Check your Paperclip startup logs for the first admin invite URL, or run this command to rotate it:" + : "No instance admin exists yet. Run this command in your Paperclip environment to generate the first admin invite URL:"} +

+
+{`pnpm paperclipai auth bootstrap-ceo`}
+        
+
+
+ ); +} + +function NoBoardAccessPage() { + return ( +
+
+

No company access

+

+ This account is signed in, but it does not have an active company membership or instance-admin access on + this Paperclip instance. +

+

+ Use a company invite or sign in with an account that already belongs to this org. +

+
+
+ ); +} + +export function CloudAccessGate() { + const location = useLocation(); + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + refetchInterval: (query) => { + const data = query.state.data as + | { deploymentMode?: "local_trusted" | "authenticated"; bootstrapStatus?: "ready" | "bootstrap_pending" } + | undefined; + return data?.deploymentMode === "authenticated" && data.bootstrapStatus === "bootstrap_pending" + ? 2000 + : false; + }, + refetchIntervalInBackground: true, + }); + + const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; + const sessionQuery = useQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => authApi.getSession(), + enabled: isAuthenticatedMode, + retry: false, + }); + + const boardAccessQuery = useQuery({ + queryKey: queryKeys.access.currentBoardAccess, + queryFn: () => accessApi.getCurrentBoardAccess(), + enabled: isAuthenticatedMode && !!sessionQuery.data, + retry: false, + }); + + if ( + healthQuery.isLoading || + (isAuthenticatedMode && sessionQuery.isLoading) || + (isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading) + ) { + return
Loading...
; + } + + if (healthQuery.error || boardAccessQuery.error) { + return ( +
+ {healthQuery.error instanceof Error + ? healthQuery.error.message + : boardAccessQuery.error instanceof Error + ? boardAccessQuery.error.message + : "Failed to load app state"} +
+ ); + } + + if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { + return ; + } + + if (isAuthenticatedMode && !sessionQuery.data) { + const next = encodeURIComponent(`${location.pathname}${location.search}`); + return ; + } + + if ( + isAuthenticatedMode && + sessionQuery.data && + !boardAccessQuery.data?.isInstanceAdmin && + (boardAccessQuery.data?.companyIds.length ?? 0) === 0 + ) { + return ; + } + + return ; +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index 09498f40271..26e47f2184e 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -96,6 +96,7 @@ export const queryKeys = { adminUsers: (query: string) => ["access", "admin-users", query] as const, userCompanyAccess: (userId: string) => ["access", "user-company-access", userId] as const, invite: (token: string) => ["access", "invite", token] as const, + currentBoardAccess: ["access", "current-board-access"] as const, }, auth: { session: ["auth", "session"] as const,