diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index bf8dbdd4c72..96c78a43cad 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -125,19 +125,50 @@ When running `authenticated` mode, if the only instance admin is `local-board`, This prevents lockout when a user migrates from long-running local trusted usage to authenticated mode. -## 8. Current Code Reality (As Of 2026-02-23) +## 8. First Admin Setup For Fresh Authenticated Installs + +Fresh authenticated installs start in `bootstrap_pending` until the first +`instance_admin` exists. + +For `authenticated/private`, Paperclip supports a browser-first setup path: + +1. open the Paperclip URL from the private network or appliance UI +2. sign in or create a Paperclip account +3. choose `Claim this instance` on the setup screen + +That browser claim promotes the signed-in session user to the first instance +admin and then falls through to normal onboarding. The endpoint is available +only to real browser session actors in `authenticated/private`; unauthenticated +requests, agent keys, board API keys, and local implicit board actors are +rejected. + +The CLI fallback remains supported in all authenticated setup states: + +```sh +pnpm paperclipai auth bootstrap-ceo +``` + +That command prints a one-time first-admin invite URL. Browser claim and +bootstrap invite acceptance share the same first-admin transaction, so whichever +path wins first makes later attempts return a conflict. + +For `authenticated/public`, browser first-admin claim is intentionally disabled. +Public deployments must use the high-entropy bootstrap invite path unless a +future public-hosted setup design explicitly changes this policy. + +## 9. Current Code Reality (As Of 2026-02-23) - runtime values are `local_trusted | authenticated` - `authenticated` uses Better Auth sessions and bootstrap invite flow - `local_trusted` ensures a real local Board user principal in `authUsers` with `instance_user_roles` admin access - company creation ensures creator membership in `company_memberships` so user assignment/access flows remain consistent -## 9. Naming and Compatibility Policy +## 10. Naming and Compatibility Policy - canonical naming is `local_trusted` and `authenticated` with `private/public` exposure - no long-term compatibility alias layer for discarded naming variants -## 10. Relationship to Other Docs +## 11. Relationship to Other Docs - implementation plan: `doc/plans/deployment-auth-mode-consolidation.md` - V1 contract: `doc/SPEC-implementation.md` diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 341f3b1117e..ec1789a23ea 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -72,6 +72,13 @@ pnpm dev --bind lan ``` This runs dev as `authenticated/private` with a private-network bind preset. +On a fresh authenticated/private instance, open the app, sign in or create an +account, and use the setup screen to claim the first instance admin from the +browser. The CLI fallback remains: + +```sh +pnpm paperclipai auth bootstrap-ceo +``` For Tailscale-only reachability on a detected tailnet address: diff --git a/doc/DOCKER.md b/doc/DOCKER.md index 056a7bdf043..bc1fae8cae9 100644 --- a/doc/DOCKER.md +++ b/doc/DOCKER.md @@ -117,6 +117,16 @@ services: - bootstrap invite URL defaults - hostname allowlist defaults (hostname extracted from URL) +For fresh `authenticated/private` Docker or appliance-style installs, the first +admin can now be claimed entirely from the browser after sign-in. Open the +Paperclip URL, sign in or create an account, then choose `Claim this instance` +on the setup screen. This browser claim is disabled for `authenticated/public`; +public deployments should run the high-entropy CLI invite fallback instead: + +```sh +pnpm paperclipai auth bootstrap-ceo +``` + Granular overrides remain available if needed (`PAPERCLIP_AUTH_PUBLIC_BASE_URL`, `BETTER_AUTH_URL`, `BETTER_AUTH_TRUSTED_ORIGINS`, `PAPERCLIP_ALLOWED_HOSTNAMES`). Set `PAPERCLIP_ALLOWED_HOSTNAMES` explicitly only when you need additional hostnames beyond the public URL host (for example Tailscale/LAN aliases or multiple private hostnames). diff --git a/server/src/__tests__/bootstrap-claim-routes.test.ts b/server/src/__tests__/bootstrap-claim-routes.test.ts new file mode 100644 index 00000000000..d58daf42f4f --- /dev/null +++ b/server/src/__tests__/bootstrap-claim-routes.test.ts @@ -0,0 +1,231 @@ +import express from "express"; +import request from "supertest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createHash } from "node:crypto"; +import { accessRoutes } from "../routes/access.js"; +import { boardMutationGuard } from "../middleware/board-mutation-guard.js"; +import { errorHandler } from "../middleware/index.js"; + +const claimFirstInstanceAdminMock = vi.hoisted(() => vi.fn()); +const accessServiceMock = vi.hoisted(() => ({ + isInstanceAdmin: vi.fn(), + canUser: vi.fn(), + hasPermission: vi.fn(), + ensureMembership: vi.fn(), + setPrincipalGrants: vi.fn(), +})); + +vi.mock("../first-admin-claim.js", () => ({ + claimFirstInstanceAdmin: claimFirstInstanceAdminMock, +})); + +vi.mock("../services/index.js", () => ({ + accessService: () => accessServiceMock, + agentService: () => ({ + getById: vi.fn(), + }), + boardAuthService: () => ({ + createCliAuthChallenge: vi.fn(), + resolveBoardAccess: vi.fn(), + assertCurrentBoardKey: vi.fn(), + revokeBoardApiKey: vi.fn(), + }), + deduplicateAgentName: vi.fn(), + logActivity: vi.fn(), + notifyHireApproved: vi.fn(), +})); + +function hashToken(token: string) { + return createHash("sha256").update(token).digest("hex"); +} + +function createDb(invite?: Record) { + return { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => Promise.resolve(invite ? [invite] : [])), + })), + })), + } as any; +} + +function createApp(input: { + actor?: Record; + deploymentMode?: "authenticated" | "local_trusted"; + deploymentExposure?: "private" | "public"; + guardMutations?: boolean; + db?: Record; +}) { + const app = express(); + app.use(express.json()); + app.use((req, _res, next) => { + (req as any).actor = input.actor ?? { + type: "board", + source: "session", + userId: "user-1", + }; + next(); + }); + if (input.guardMutations) { + app.use(boardMutationGuard()); + } + app.use( + "/api", + accessRoutes(input.db as any ?? createDb(), { + deploymentMode: input.deploymentMode ?? "authenticated", + deploymentExposure: input.deploymentExposure ?? "private", + bindHost: "127.0.0.1", + allowedHostnames: [], + }), + ); + app.use(errorHandler); + return app; +} + +describe("POST /bootstrap/claim", () => { + beforeEach(() => { + vi.clearAllMocks(); + claimFirstInstanceAdminMock.mockResolvedValue({ + status: "claimed", + userId: "user-1", + value: null, + }); + }); + + it("claims first admin for an authenticated private browser session", async () => { + const app = createApp({}); + + const res = await request(app).post("/api/bootstrap/claim").send({}); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ claimed: true, userId: "user-1" }); + expect(claimFirstInstanceAdminMock).toHaveBeenCalledWith(expect.anything(), { userId: "user-1" }); + }); + + it("is not exposed in authenticated public mode", async () => { + const app = createApp({ deploymentExposure: "public" }); + + const res = await request(app).post("/api/bootstrap/claim").send({}); + + expect(res.status).toBe(404); + expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled(); + }); + + it("is not exposed in local trusted mode", async () => { + const app = createApp({ deploymentMode: "local_trusted" }); + + const res = await request(app).post("/api/bootstrap/claim").send({}); + + expect(res.status).toBe(404); + expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled(); + }); + + it.each([ + [{ type: "none", source: "none" }, "anonymous caller"], + [{ type: "agent", source: "agent_key", agentId: "agent-1" }, "agent key"], + [{ type: "board", source: "board_key", userId: "user-1" }, "board API key"], + [{ type: "board", source: "local_implicit", userId: "local-board" }, "local implicit board"], + ])("rejects %s before opening the first-admin transaction", async (actor) => { + const app = createApp({ actor }); + + const res = await request(app).post("/api/bootstrap/claim").send({}); + + expect(res.status).toBe(401); + expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled(); + }); + + it("returns conflict when first admin has already been claimed", async () => { + claimFirstInstanceAdminMock.mockResolvedValueOnce({ + status: "already_claimed", + existingUserId: "user-2", + value: null, + }); + const app = createApp({}); + + const res = await request(app).post("/api/bootstrap/claim").send({}); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("already claimed"); + }); + + it("stays behind the board mutation origin guard", async () => { + const app = createApp({ guardMutations: true }); + + const blocked = await request(app).post("/api/bootstrap/claim").send({}); + expect(blocked.status).toBe(403); + expect(claimFirstInstanceAdminMock).not.toHaveBeenCalled(); + + const allowed = await request(app) + .post("/api/bootstrap/claim") + .set("Host", "paperclip.local") + .set("Origin", "http://paperclip.local") + .send({}); + expect(allowed.status).toBe(200); + expect(claimFirstInstanceAdminMock).toHaveBeenCalledTimes(1); + }); +}); + +describe("bootstrap invite first-admin acceptance", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + function createBootstrapInvite() { + return { + id: "invite-1", + companyId: null, + inviteType: "bootstrap_ceo", + allowedJoinTypes: "human", + tokenHash: hashToken("pcp_invite_test"), + defaultsPayload: {}, + expiresAt: new Date("2027-03-10T00:00:00.000Z"), + invitedByUserId: null, + revokedAt: null, + acceptedAt: null, + createdAt: new Date("2026-03-07T00:00:00.000Z"), + updatedAt: new Date("2026-03-07T00:00:00.000Z"), + }; + } + + it("uses the shared first-admin helper for bootstrap invite acceptance", async () => { + const invite = createBootstrapInvite(); + claimFirstInstanceAdminMock.mockResolvedValueOnce({ + status: "claimed", + userId: "user-1", + value: { ...invite, acceptedAt: new Date("2026-03-07T00:01:00.000Z") }, + }); + const app = createApp({ db: createDb(invite) }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(202); + expect(res.body).toMatchObject({ + inviteId: "invite-1", + inviteType: "bootstrap_ceo", + bootstrapAccepted: true, + userId: "user-1", + }); + expect(claimFirstInstanceAdminMock).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ userId: "user-1", onClaim: expect.any(Function) }), + ); + }); + + it("conflicts cleanly when browser claim already won before invite acceptance", async () => { + claimFirstInstanceAdminMock.mockResolvedValueOnce({ + status: "already_claimed", + existingUserId: "user-2", + value: null, + }); + const app = createApp({ db: createDb(createBootstrapInvite()) }); + + const res = await request(app) + .post("/api/invites/pcp_invite_test/accept") + .send({ requestType: "human" }); + + expect(res.status).toBe(409); + expect(res.body.error).toContain("already claimed"); + }); +}); diff --git a/server/src/__tests__/first-admin-claim.test.ts b/server/src/__tests__/first-admin-claim.test.ts new file mode 100644 index 00000000000..a0461a89dbf --- /dev/null +++ b/server/src/__tests__/first-admin-claim.test.ts @@ -0,0 +1,56 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest"; +import { createDb, instanceUserRoles } from "@paperclipai/db"; +import { + getEmbeddedPostgresTestSupport, + startEmbeddedPostgresTestDatabase, +} from "./helpers/embedded-postgres.js"; +import { claimFirstInstanceAdmin } from "../first-admin-claim.js"; + +const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); +const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; + +describeEmbeddedPostgres("claimFirstInstanceAdmin", () => { + let db!: ReturnType; + let tempDb: Awaited> | null = null; + + beforeAll(async () => { + tempDb = await startEmbeddedPostgresTestDatabase("paperclip-first-admin-claim-"); + db = createDb(tempDb.connectionString); + }, 20_000); + + afterEach(async () => { + await db.delete(instanceUserRoles); + }); + + afterAll(async () => { + await tempDb?.cleanup(); + }); + + it("inserts exactly one first admin and reports later claims as conflicts", async () => { + const firstUserId = `user-${randomUUID()}`; + const first = await claimFirstInstanceAdmin(db, { userId: firstUserId }); + + expect(first).toMatchObject({ status: "claimed", userId: firstUserId }); + + const second = await claimFirstInstanceAdmin(db, { userId: `user-${randomUUID()}` }); + expect(second).toMatchObject({ status: "already_claimed", existingUserId: firstUserId }); + + const roles = await db.select().from(instanceUserRoles); + expect(roles).toHaveLength(1); + expect(roles[0]).toMatchObject({ userId: firstUserId, role: "instance_admin" }); + }); + + it("runs onClaim inside the winning transaction", async () => { + const userId = `user-${randomUUID()}`; + const result = await claimFirstInstanceAdmin(db, { + userId, + onClaim: async (tx) => { + const roles = await tx.select().from(instanceUserRoles); + return roles.map((role) => role.userId); + }, + }); + + expect(result).toMatchObject({ status: "claimed", userId, value: [userId] }); + }); +}); diff --git a/server/src/__tests__/health.test.ts b/server/src/__tests__/health.test.ts index 2d294c0e0d3..66bf9a7749f 100644 --- a/server/src/__tests__/health.test.ts +++ b/server/src/__tests__/health.test.ts @@ -97,6 +97,7 @@ describe("GET /health", () => { expect(res.body).toEqual({ status: "ok", deploymentMode: "authenticated", + deploymentExposure: "public", bootstrapStatus: "ready", bootstrapInviteActive: false, }); @@ -131,6 +132,7 @@ describe("GET /health", () => { expect(res.body).toEqual({ status: "ok", deploymentMode: "authenticated", + deploymentExposure: "public", bootstrapStatus: "ready", bootstrapInviteActive: false, }); diff --git a/server/src/first-admin-claim.ts b/server/src/first-admin-claim.ts new file mode 100644 index 00000000000..b134348f0b6 --- /dev/null +++ b/server/src/first-admin-claim.ts @@ -0,0 +1,55 @@ +import { eq, sql } from "drizzle-orm"; +import type { Db } from "@paperclipai/db"; +import { instanceUserRoles } from "@paperclipai/db"; + +type FirstAdminTransaction = Pick; + +export type FirstAdminClaimResult = + | { + status: "claimed"; + userId: string; + value: T | null; + } + | { + status: "already_claimed"; + existingUserId: string | null; + value: null; + }; + +export async function claimFirstInstanceAdmin( + db: Db, + input: { + userId: string; + onClaim?: (tx: FirstAdminTransaction) => Promise; + }, +): Promise> { + return db.transaction(async (tx) => { + await tx.execute(sql`lock table ${instanceUserRoles} in share row exclusive mode`); + + const existingAdmin = await tx + .select({ userId: instanceUserRoles.userId }) + .from(instanceUserRoles) + .where(eq(instanceUserRoles.role, "instance_admin")) + .then((rows) => rows[0] ?? null); + + if (existingAdmin) { + return { + status: "already_claimed" as const, + existingUserId: existingAdmin.userId ?? null, + value: null, + }; + } + + await tx.insert(instanceUserRoles).values({ + userId: input.userId, + role: "instance_admin", + }); + + const value = input.onClaim ? await input.onClaim(tx) : null; + return { + status: "claimed" as const, + userId: input.userId, + value, + }; + }); +} diff --git a/server/src/routes/access.ts b/server/src/routes/access.ts index 44b1fbaf6f4..0478f60ee14 100644 --- a/server/src/routes/access.ts +++ b/server/src/routes/access.ts @@ -79,6 +79,7 @@ import { claimBoardOwnership, inspectBoardClaimChallenge } from "../board-claim.js"; +import { claimFirstInstanceAdmin } from "../first-admin-claim.js"; import { getStorageService } from "../storage/index.js"; function hashToken(token: string) { @@ -2453,6 +2454,31 @@ export function accessRoutes( throw conflict("Board claim challenge is no longer available"); }); + router.post("/bootstrap/claim", async (req, res) => { + if ( + opts.deploymentMode !== "authenticated" || + opts.deploymentExposure !== "private" + ) { + throw notFound("Browser first-admin claim is not available"); + } + if ( + req.actor.type !== "board" || + req.actor.source !== "session" || + !req.actor.userId + ) { + throw unauthorized("Sign in from a browser session before claiming first admin"); + } + + const claimed = await claimFirstInstanceAdmin(db, { + userId: req.actor.userId, + }); + if (claimed.status === "already_claimed") { + throw conflict("Someone else has already claimed this instance"); + } + + res.json({ claimed: true, userId: claimed.userId }); + }); + router.post( "/cli-auth/challenges", validate(createCliAuthChallengeSchema), @@ -3276,16 +3302,31 @@ export function accessRoutes( ); } const userId = req.actor.userId ?? "local-board"; - const existingAdmin = await access.isInstanceAdmin(userId); - if (!existingAdmin) { - await access.promoteInstanceAdmin(userId); + const claimed = await claimFirstInstanceAdmin(db, { + userId, + onClaim: async (tx) => { + const updatedInvite = await tx + .update(invites) + .set({ acceptedAt: new Date(), updatedAt: new Date() }) + .where( + and( + eq(invites.id, invite.id), + isNull(invites.acceptedAt), + isNull(invites.revokedAt) + ) + ) + .returning() + .then((rows) => rows[0] ?? null); + if (!updatedInvite) { + throw conflict("Bootstrap invite is no longer available"); + } + return updatedInvite; + }, + }); + if (claimed.status === "already_claimed") { + throw conflict("Someone else has already claimed this instance"); } - const updatedInvite = await db - .update(invites) - .set({ acceptedAt: new Date(), updatedAt: new Date() }) - .where(eq(invites.id, invite.id)) - .returning() - .then((rows) => rows[0] ?? invite); + const updatedInvite = claimed.value ?? invite; res.status(202).json({ inviteId: updatedInvite.id, inviteType: updatedInvite.inviteType, diff --git a/server/src/routes/health.ts b/server/src/routes/health.ts index 80a2dd7016e..f5a99ce396c 100644 --- a/server/src/routes/health.ts +++ b/server/src/routes/health.ts @@ -157,6 +157,7 @@ export function healthRoutes( res.json({ status: "ok", deploymentMode: opts.deploymentMode, + deploymentExposure: opts.deploymentExposure, bootstrapStatus, bootstrapInviteActive, ...(devServer ? { devServer } : {}), diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index bec9c199ae2..d1b14463b0b 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -1,6 +1,7 @@ // @vitest-environment jsdom -import { act, type ReactNode } from "react"; +import type { ReactNode } from "react"; +import { flushSync } from "react-dom"; import { createRoot } from "react-dom/client"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -16,6 +17,7 @@ const mockAuthApi = vi.hoisted(() => ({ const mockAccessApi = vi.hoisted(() => ({ getCurrentBoardAccess: vi.fn(), + claimBootstrapAdmin: vi.fn(), })); vi.mock("./api/health", () => ({ @@ -31,6 +33,7 @@ vi.mock("./api/access", () => ({ })); vi.mock("@/lib/router", () => ({ + Link: ({ to, children }: { to: string; children?: ReactNode }) => {children}, Navigate: ({ to }: { to: string }) =>
Navigate:{to}
, Outlet: () =>
Outlet content
, Route: ({ children }: { children?: ReactNode }) => <>{children}, @@ -43,10 +46,16 @@ vi.mock("@/lib/router", () => ({ (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)); - }); + await Promise.resolve(); + await new Promise((resolve) => window.setTimeout(resolve, 0)); +} + +async function waitForText(container: HTMLElement, text: string) { + for (let attempt = 0; attempt < 20; attempt += 1) { + if (container.textContent?.includes(text)) return; + await flushReact(); + } + expect(container.textContent).toContain(text); } describe("CloudAccessGate", () => { @@ -58,6 +67,7 @@ describe("CloudAccessGate", () => { mockHealthApi.get.mockResolvedValue({ status: "ok", deploymentMode: "authenticated", + deploymentExposure: "private", bootstrapStatus: "ready", }); }); @@ -87,21 +97,19 @@ describe("CloudAccessGate", () => { defaultOptions: { queries: { retry: false } }, }); - await act(async () => { + flushSync(() => { root.render( , ); }); - await flushReact(); - await flushReact(); - await flushReact(); + await waitForText(container, "No company access"); expect(container.textContent).toContain("No company access"); expect(container.textContent).not.toContain("Outlet content"); - await act(async () => { + flushSync(() => { root.unmount(); }); }); @@ -125,21 +133,140 @@ describe("CloudAccessGate", () => { defaultOptions: { queries: { retry: false } }, }); - await act(async () => { + flushSync(() => { root.render( , ); }); - await flushReact(); - await flushReact(); - await flushReact(); + await waitForText(container, "Outlet content"); expect(container.textContent).toContain("Outlet content"); expect(container.textContent).not.toContain("No company access"); - await act(async () => { + flushSync(() => { + root.unmount(); + }); + }); + + it("shows browser sign-in setup for signed-out private bootstrap-pending instances", async () => { + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "private", + bootstrapStatus: "bootstrap_pending", + bootstrapInviteActive: false, + }); + mockAuthApi.getSession.mockResolvedValue(null); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + flushSync(() => { + root.render( + + + , + ); + }); + await waitForText(container, "Finish setting up this Paperclip"); + + expect(container.textContent).toContain("Finish setting up this Paperclip"); + expect(container.textContent).toContain("Sign in / Create account"); + expect(container.textContent).toContain("pnpm paperclipai auth bootstrap-ceo"); + expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled(); + + flushSync(() => { + root.unmount(); + }); + }); + + it("shows the claim action for signed-in private bootstrap-pending instances", async () => { + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "private", + bootstrapStatus: "bootstrap_pending", + bootstrapInviteActive: false, + }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + mockAccessApi.claimBootstrapAdmin.mockResolvedValue({ claimed: true, userId: "user-1" }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + flushSync(() => { + root.render( + + + , + ); + }); + await waitForText(container, "Claim this instance"); + + expect(container.textContent).toContain("Claim this instance"); + expect(container.textContent).toContain("Signed in as user@example.com"); + expect(mockAccessApi.getCurrentBoardAccess).not.toHaveBeenCalled(); + + const button = Array.from(container.querySelectorAll("button")).find((candidate) => + candidate.textContent?.includes("Claim this instance"), + ); + expect(button).toBeTruthy(); + flushSync(() => { + button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await waitForText(container, "You're the instance admin"); + + expect(mockAccessApi.claimBootstrapAdmin).toHaveBeenCalledTimes(1); + expect(container.textContent).toContain("You're the instance admin"); + + flushSync(() => { + root.unmount(); + }); + }); + + it("keeps public bootstrap-pending instances invite-only", async () => { + mockHealthApi.get.mockResolvedValue({ + status: "ok", + deploymentMode: "authenticated", + deploymentExposure: "public", + bootstrapStatus: "bootstrap_pending", + bootstrapInviteActive: true, + }); + mockAuthApi.getSession.mockResolvedValue({ + session: { id: "session-1", userId: "user-1" }, + user: { id: "user-1", email: "user@example.com", name: "User", image: null }, + }); + + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + flushSync(() => { + root.render( + + + , + ); + }); + await waitForText(container, "This Paperclip is waiting on its first admin"); + + expect(container.textContent).toContain("This Paperclip is waiting on its first admin"); + expect(container.textContent).toContain("invite-only mode"); + expect(container.textContent).not.toContain("Claim this instance"); + expect(container.textContent).not.toContain("Sign in / Create account"); + expect(mockAccessApi.claimBootstrapAdmin).not.toHaveBeenCalled(); + + flushSync(() => { root.unmount(); }); }); diff --git a/ui/src/api/access.ts b/ui/src/api/access.ts index b5b68946c08..dc304757594 100644 --- a/ui/src/api/access.ts +++ b/ui/src/api/access.ts @@ -384,6 +384,9 @@ export const accessApi = { claimBoard: (token: string, code: string) => api.post<{ claimed: true; userId: string }>(`/board-claim/${token}/claim`, { code }), + claimBootstrapAdmin: () => + api.post<{ claimed: true; userId: string }>("/bootstrap/claim", {}), + getCliAuthChallenge: (id: string, token: string) => api.get(`/cli-auth/challenges/${id}?token=${encodeURIComponent(token)}`), diff --git a/ui/src/components/BootstrapPendingPage.tsx b/ui/src/components/BootstrapPendingPage.tsx new file mode 100644 index 00000000000..ab3784d2b1a --- /dev/null +++ b/ui/src/components/BootstrapPendingPage.tsx @@ -0,0 +1,172 @@ +import type { ReactNode } from "react"; +import { Loader2, ShieldCheck, Terminal, TriangleAlert } from "lucide-react"; +import { Link } from "@/lib/router"; +import { Button } from "@/components/ui/button"; +import type { AuthSession } from "@paperclipai/shared"; + +const FALLBACK_COMMAND = "pnpm paperclipai auth bootstrap-ceo"; + +type BootstrapPendingPageProps = { + claimAvailable: boolean; + hasActiveInvite?: boolean; + session: AuthSession | null | undefined; + claimState: "idle" | "claiming" | "success"; + claimError?: { status?: number; message?: string } | null; + onClaim: () => void; +}; + +function CliFallback({ hasActiveInvite = false }: { hasActiveInvite?: boolean }) { + return ( +
+
+ + Prefer to finish setup from the host? +
+

+ {hasActiveInvite + ? "A bootstrap invite is already active. Check your Paperclip startup logs for the first-admin URL, or run this command on the host to rotate it:" + : "Run this command on the host that runs Paperclip to print a one-time first-admin invite URL:"} +

+
+{FALLBACK_COMMAND}
+      
+
+ ); +} + +function StateChrome({ children }: { children: ReactNode }) { + return ( +
+
{children}
+
+ ); +} + +function displayIdentity(session: AuthSession) { + return session.user.email || session.user.name || session.user.id; +} + +function claimErrorCopy(error: BootstrapPendingPageProps["claimError"]) { + if (error?.status === 409) { + return { + title: "Someone else has already claimed this instance.", + body: "Refresh to sign in, or ask the existing admin to invite you from Instance settings -> Access.", + }; + } + if (error?.status === 401) { + return { + title: "Your session expired. Sign in again to claim this instance.", + body: "", + }; + } + return { + title: "We couldn't reach the server. Try again in a moment.", + body: "", + }; +} + +export function BootstrapPendingPage({ + claimAvailable, + hasActiveInvite = false, + session, + claimState, + claimError, + onClaim, +}: BootstrapPendingPageProps) { + if (!claimAvailable) { + return ( + +

This Paperclip is waiting on its first admin

+

+ This instance runs in invite-only mode. The operator must generate a one-time first-admin invite URL + from the host. Once you have the link, open it from this browser to finish setup. +

+ +

+ Browser-based claim is intentionally disabled in public mode so anyone on the network can't promote + themselves. +

+
+ ); + } + + if (claimState === "success") { + return ( + +
+
+ +
+
+

You're the instance admin

+

+ Setup is complete. Taking you to onboarding to create your first company... +

+
+
+
+ + Redirecting... +
+
+ ); + } + + if (!session) { + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Sign in or create your Paperclip account to become the first + admin from this browser. +

+
+ +
+ +
+ ); + } + + const errorCopy = claimErrorCopy(claimError); + const isClaiming = claimState === "claiming"; + return ( + +

Finish setting up this Paperclip

+

+ No admin has claimed this instance yet. Claim it now to become the first admin and start onboarding. +

+
+ + + Signed in as {displayIdentity(session)} + +
+

+ Wrong account?{" "} + + Switch account + + . +

+ {claimError && ( +
+ +
+

{errorCopy.title}

+ {errorCopy.body &&

{errorCopy.body}

} +
+
+ )} + +
+ ); +} diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx index eaea736bcdf..0ec4592676b 100644 --- a/ui/src/components/CloudAccessGate.tsx +++ b/ui/src/components/CloudAccessGate.tsx @@ -1,27 +1,11 @@ import { Navigate, Outlet, useLocation } from "@/lib/router"; -import { useQuery } from "@tanstack/react-query"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "@/api/access"; +import { ApiError } from "@/api/client"; 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`}
-        
-
-
- ); -} +import { BootstrapPendingPage } from "@/components/BootstrapPendingPage"; function NoBoardAccessPage() { return ( @@ -42,6 +26,7 @@ function NoBoardAccessPage() { export function CloudAccessGate() { const location = useLocation(); + const queryClient = useQueryClient(); const healthQuery = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), @@ -58,6 +43,7 @@ export function CloudAccessGate() { }); const isAuthenticatedMode = healthQuery.data?.deploymentMode === "authenticated"; + const isBootstrapPending = isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending"; const sessionQuery = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), @@ -68,14 +54,24 @@ export function CloudAccessGate() { const boardAccessQuery = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), - enabled: isAuthenticatedMode && !!sessionQuery.data, + enabled: isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data, retry: false, }); + const claimMutation = useMutation({ + mutationFn: () => accessApi.claimBootstrapAdmin(), + onSuccess: async () => { + await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); + await queryClient.invalidateQueries({ queryKey: queryKeys.health }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); + await queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats }); + await queryClient.invalidateQueries({ queryKey: queryKeys.access.currentBoardAccess }); + }, + }); if ( healthQuery.isLoading || (isAuthenticatedMode && sessionQuery.isLoading) || - (isAuthenticatedMode && !!sessionQuery.data && boardAccessQuery.isLoading) + (isAuthenticatedMode && !isBootstrapPending && !!sessionQuery.data && boardAccessQuery.isLoading) ) { return
Loading...
; } @@ -92,8 +88,26 @@ export function CloudAccessGate() { ); } - if (isAuthenticatedMode && healthQuery.data?.bootstrapStatus === "bootstrap_pending") { - return ; + if (isBootstrapPending) { + const health = healthQuery.data; + if (!health) { + return
Loading...
; + } + const claimError = claimMutation.error instanceof ApiError + ? { status: claimMutation.error.status, message: claimMutation.error.message } + : claimMutation.error instanceof Error + ? { message: claimMutation.error.message } + : null; + return ( + claimMutation.mutate()} + /> + ); } if (isAuthenticatedMode && !sessionQuery.data) {