From d9d2147171844c4f4c80dec4db7edc0a04ac8f07 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 28 Sep 2026 13:45:27 -0500 Subject: [PATCH] fix(auth): keep Cloud tenants on the Cloud sign-in flow (#14407) ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Cloud owns human identity and passes a verified identity to each tenant. > - The tenant can report no session while the Cloud session is still valid. > - The access gate and direct `/auth` route then show the instance password form. > - This pull request sends those users through the configured Cloud entry endpoint. > - Cloud can renew the tenant session or show its login page, then return to the original task. ## Linked Issues or Issue Description **What happened?** A Cloud tenant can display the self-hosted email/password form after an instance session check returns no session. This gives Cloud users the wrong login method. **Expected behavior** An active Cloud session renews tenant access automatically. A signed-out user signs in through Cloud. Staging and production use their own configured Cloud origins. Self-hosted instances keep their instance login form. **Steps to reproduce** 1. Open a Cloud tenant task or an `/auth?next=...` link. 2. Keep the Cloud session active but make the instance session check return 401. 3. Observe the instance password form instead of Cloud session recovery. **Deployment mode** Cloud-managed authenticated instances. No database or server API changes. Searched related authentication PRs. Native self-hosted OIDC support in #10411 is a separate feature; this change uses the existing Cloud entry contract. ## What Changed - Wait for deployment metadata before showing an instance login form. - Use the health response's Cloud origin and stack slug for session recovery. - Preserve the tenant path, query, and fragment. Reject external and recursive login return targets. - Limit automatic recovery per tab. Show a manual Cloud retry after failed recovery. Show service failures as errors. - Keep self-hosted login and local trusted access. Add focused tests, browser regressions, deployment documentation, and an unavailable-state design example. ## Verification - `pnpm -r typecheck` and `pnpm build` passed. - UI typecheck and `pnpm check:token-gates` passed after the final UI edits. - All UI tests passed: 639 files, 6,777 tests. - 63 focused Vitest tests passed across Auth, CloudAccessGate, Cloud links, and recovery coordination. - `pnpm exec playwright test --config tests/e2e/playwright.config.ts tests/e2e/cloud-auth.spec.ts`: 5 passed. The tests use the real tenant UI and database with a simulated Cloud HTTP endpoint. They cover both Cloud origins, direct auth/task links, no password-form flash, preserved URLs, reload, and self-hosted login. - Hands-on browser test used the real Cloud gateway and a fresh tenant build with disposable local data. Active Cloud session plus a forced missing instance session returned to the task. An expired tenant cookie also renewed automatically and returned to the task. Removing both sessions reached the real Cloud email/social login UI. Persistent failure stopped at the retry screen; retry succeeded after removing the injected fault. The fixture used a loopback transport adapter and a simulated signed-out OIDC issuer. No production session or deployment was changed. - The default local browser startup hit the host's embedded PostgreSQL resource limit. The passing run used a separate disposable database on the test PostgreSQL process. - The full local `pnpm test:run` sweep was stopped after about 31 minutes once CI completed the full suite. It had reported 33 failures in the unchanged runner API unit/integration files; both files pass in isolation (1,749 + 28 tests). The local sweep did not reach the later workspace/serialized groups. CI completed all of those groups successfully. - Greptile reviewed commit `d603fd4e39455de44da9dae81b72197096c0e1e8` at 5/5 with its only thread resolved. All CI gates are green on this commit, including all general/serialized server groups, workspace tests, Runner checks, typecheck, build, and all eight browser shards ([run](https://github.com/paperclipai/paperclip/actions/runs/36446230697)). ## Risks - Recovery depends on valid Cloud origin and stack metadata. Incomplete metadata shows an unavailable message instead of a password form. - Browsers with session storage disabled use the manual Cloud link, since automatic retries cannot be bounded across documents. - The external identity provider's email/social login was not completed in this local test. Existing Cloud authentication owns that flow. - No migration, credential format, membership rule, or production deployment changes. ## Model Used OpenAI GPT-6 through Codex. The exact served variant and context-window limit are not exposed in this session. Used reasoning, repository tools, code execution, and browser testing. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/DEPLOYMENT-MODES.md | 21 +++++++++ tests/e2e/cloud-auth.spec.ts | 64 +++++++++++++++++++++++++++ ui/src/App.test.tsx | 45 ++++++++++++++++++- ui/src/components/CloudAccessGate.tsx | 21 +++++++-- ui/src/components/CloudSignIn.tsx | 36 +++++++++++++++ ui/src/lib/cloud-sign-in.test.ts | 36 +++++++++++++++ ui/src/lib/cloud-sign-in.ts | 24 ++++++++++ ui/src/lib/cloudLinks.test.ts | 22 ++++++++- ui/src/lib/cloudLinks.ts | 23 ++++++++++ ui/src/pages/Auth.test.tsx | 62 ++++++++++++++++++++++++-- ui/src/pages/Auth.tsx | 25 +++++++++-- ui/src/pages/DesignGuide.tsx | 5 +++ 12 files changed, 371 insertions(+), 13 deletions(-) create mode 100644 tests/e2e/cloud-auth.spec.ts create mode 100644 ui/src/components/CloudSignIn.tsx create mode 100644 ui/src/lib/cloud-sign-in.test.ts create mode 100644 ui/src/lib/cloud-sign-in.ts diff --git a/doc/DEPLOYMENT-MODES.md b/doc/DEPLOYMENT-MODES.md index 04a9897973..99dd553226 100644 --- a/doc/DEPLOYMENT-MODES.md +++ b/doc/DEPLOYMENT-MODES.md @@ -3,6 +3,27 @@ Status: Canonical deployment and auth mode model Date: 2026-02-23 +### Paperclip Cloud sign-in + +Cloud-managed instances use Cloud for human sign-in. The instance `/auth` +route waits for deployment metadata before rendering; it never renders the +email/password form when health identifies a Cloud-managed instance. A missing +instance session returns through Cloud's `/v1/stacks/:slug/entry-redirect`. +Cloud renews the tenant session from the existing Cloud session, or sends the +user through its sign-in flow. The original tenant path, query, and fragment +travel as `returnTo` so the user returns to the same task. + +Both the Cloud origin and stack slug come from the server's health metadata +(`PAPERCLIP_CLOUD_API_ORIGIN` and `PAPERCLIP_STACK_SLUG`). Do not infer the +environment from the browser hostname or hardcode staging/production domains. +Missing configuration shows an unavailable state. An automatic recovery attempt +is limited per browser tab until a session is verified, with a five-minute +expiry and an explicit retry link if recovery fails. Network/server failures +show an error rather than treating the user as signed out. + +Self-hosted authenticated instances retain their instance sign-in form, and +`local_trusted` instances retain their normal access path. + ## 1. Purpose Paperclip supports two runtime modes: diff --git a/tests/e2e/cloud-auth.spec.ts b/tests/e2e/cloud-auth.spec.ts new file mode 100644 index 0000000000..e18084feef --- /dev/null +++ b/tests/e2e/cloud-auth.spec.ts @@ -0,0 +1,64 @@ +import { randomUUID } from "node:crypto"; +import { expect, test } from "@playwright/test"; + +// The tenant UI and task database are real. Cloud is an external dependency: +// simulate its entry endpoint and independent session states at the HTTP edge. +for (const cloudOrigin of ["https://my.paperclip.app", "https://my-staging.paperclip.app"]) { + for (const entry of ["auth", "task"] as const) { + test(`Cloud recovery preserves the task (${cloudOrigin}, ${entry})`, async ({ page, request, baseURL }, testInfo) => { + const companyResponse = await request.post("/api/companies", { data: { name: `Cloud auth ${randomUUID()}` } }); + expect(companyResponse.ok()).toBe(true); + const company = await companyResponse.json(); + const issueResponse = await request.post(`/api/companies/${company.id}/issues`, { + data: { title: "Return here after Cloud sign-in", status: "backlog" }, + }); + expect(issueResponse.ok()).toBe(true); + const issue = await issueResponse.json(); + const target = `/${company.issuePrefix}/issues/${issue.identifier}?view=activity#recovered`; + let authenticated = false; + let handoffs = 0; + let formWasRendered = false; + await page.exposeFunction("reportInstanceLoginForm", () => { formWasRendered = true; }); + await page.addInitScript(() => { + new MutationObserver(() => { + if (document.querySelector('input[autocomplete="current-password"]')) { + void (window as unknown as { reportInstanceLoginForm: () => Promise }).reportInstanceLoginForm(); + } + }).observe(document, { childList: true, subtree: true }); + }); + await page.route("**/api/health", async (route) => { + const response = await route.fetch(); + await route.fulfill({ response, json: { + ...await response.json(), deploymentMode: "authenticated", bootstrapStatus: "ready", + cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: cloudOrigin, stackSlug: "test-workspace" }, + } }); + }); + await page.route("**/api/auth/get-session", (route) => route.fulfill(authenticated ? { + json: { session: { id: "test-session", userId: "local-board" }, user: { id: "local-board", email: "test@example.test", name: "Test user", image: null }, sentryDsn: null }, + } : { status: 401, json: { error: "Board authentication required" } })); + await page.route(`${cloudOrigin}/v1/stacks/test-workspace/entry-redirect?**`, async (route) => { + handoffs++; + expect(new URL(route.request().url()).searchParams.get("returnTo")).toBe(target); + authenticated = true; + await route.fulfill({ status: 302, headers: { Location: `${baseURL}${target}` } }); + }); + await page.goto(entry === "auth" ? `/auth?next=${encodeURIComponent(target)}` : target); + await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible(); + expect(page.url()).toBe(`${baseURL}${target}`); + expect(handoffs).toBe(1); + expect(formWasRendered).toBe(false); + await page.screenshot({ path: testInfo.outputPath("cloud-session-recovered.png") }); + await page.reload(); + await expect(page.getByRole("heading", { name: issue.title, exact: true })).toBeVisible(); + expect(handoffs).toBe(1); + expect(formWasRendered).toBe(false); + }); + } +} + +test("self-hosted auth still shows the instance sign-in form", async ({ page }) => { + await page.route("**/api/auth/get-session", (route) => route.fulfill({ status: 401, json: { error: "Board authentication required" } })); + await page.goto("/auth"); + await expect(page.getByRole("heading", { name: "Sign in to Paperclip", exact: true })).toBeVisible(); + await expect(page.getByLabel("Password", { exact: true })).toBeVisible(); +}); diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index 2cea7cea64..4730e3b782 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -6,6 +6,7 @@ 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"; +import { queryKeys } from "./lib/queryKeys"; import appSource from "./App.tsx?raw"; const mockHealthApi = vi.hoisted(() => ({ @@ -20,6 +21,11 @@ const mockAccessApi = vi.hoisted(() => ({ getCurrentBoardAccess: vi.fn(), claimBootstrapAdmin: vi.fn(), })); +const beginCloudSignInMock = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/cloud-sign-in", () => ({ + beginCloudSignIn: (url: string) => beginCloudSignInMock(url), + clearCloudSignInAttempt: vi.fn(), +})); vi.mock("./api/health", () => ({ healthApi: mockHealthApi, @@ -57,9 +63,9 @@ async function waitForText(container: HTMLElement, text: string) { await vi.waitFor(() => expect(container.textContent).toContain(text)); } -function renderGate(container: HTMLElement, allowMembershipRequest = false) { +function renderGate(container: HTMLElement, allowMembershipRequest = false, client?: QueryClient) { const root = createRoot(container); - const queryClient = new QueryClient({ + const queryClient = client ?? new QueryClient({ defaultOptions: { queries: { retry: false } }, }); @@ -100,6 +106,41 @@ describe("CloudAccessGate", () => { vi.clearAllMocks(); }); + it("renews a missing Cloud instance session without opening local auth", async () => { + mockHealthApi.get.mockResolvedValue({ deploymentMode: "authenticated", cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: "https://my-staging.paperclip.app", stackSlug: "team" } }); + mockAuthApi.getSession.mockResolvedValue(null); + beginCloudSignInMock.mockReturnValue(true); + const root = renderGate(container); + await vi.waitFor(() => expect(beginCloudSignInMock).toHaveBeenCalledTimes(1)); + expect(beginCloudSignInMock).toHaveBeenCalledWith("https://my-staging.paperclip.app/v1/stacks/team/entry-redirect?returnTo=%2Finstance%2Fsettings%2Fgeneral"); + expect(container.textContent).not.toContain("Navigate:/auth"); + expect(container.textContent).not.toContain("Outlet content"); + unmountRoot(root); + }); + + it("does not mistake a session service failure for a signed-out user", async () => { + mockAuthApi.getSession.mockRejectedValue(new Error("Session service unavailable")); + const root = renderGate(container); + await waitForText(container, "Session service unavailable"); + expect(container.textContent).not.toContain("Navigate:/auth"); + expect(beginCloudSignInMock).not.toHaveBeenCalled(); + unmountRoot(root); + }); + + it.each([undefined, { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: "https://my.paperclip.app", stackSlug: "team" }])( + "does not require a session in local trusted mode, including with Cloud metadata %j", async (cloud) => { + mockHealthApi.get.mockResolvedValue({ deploymentMode: "local_trusted", cloud }); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + await client.fetchQuery({ + queryKey: queryKeys.auth.session, + queryFn: () => Promise.reject(new Error("Session service unavailable")), + }).catch(() => {}); + const root = renderGate(container, false, client); + await waitForText(container, "Outlet content"); + expect(beginCloudSignInMock).not.toHaveBeenCalled(); + unmountRoot(root); + }); + it("shows a no-access message for signed-in users without org access", async () => { mockAuthApi.getSession.mockResolvedValue({ session: { id: "session-1", userId: "user-1" }, diff --git a/ui/src/components/CloudAccessGate.tsx b/ui/src/components/CloudAccessGate.tsx index 1dbe28b0de..ff193c467d 100644 --- a/ui/src/components/CloudAccessGate.tsx +++ b/ui/src/components/CloudAccessGate.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react"; import { Navigate, Outlet, useLocation } from "@/lib/router"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { accessApi } from "@/api/access"; @@ -8,6 +9,8 @@ import { queryKeys } from "@/lib/queryKeys"; import { BootstrapPendingPage } from "@/components/BootstrapPendingPage"; import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon"; import { Card } from "@/components/ui/card"; +import { CloudSignIn } from "@/components/CloudSignIn"; +import { clearCloudSignInAttempt } from "@/lib/cloud-sign-in"; function NoBoardAccessPage() { return ( @@ -53,6 +56,10 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe retry: false, }); + useEffect(() => { + if (sessionQuery.data) clearCloudSignInAttempt(); + }, [sessionQuery.data]); + const boardAccessQuery = useQuery({ queryKey: queryKeys.access.currentBoardAccess, queryFn: () => accessApi.getCurrentBoardAccess(), @@ -78,18 +85,24 @@ export function CloudAccessGate({ allowMembershipRequest = false }: { allowMembe return ; } - if (healthQuery.error || boardAccessQuery.error) { + if (healthQuery.error || (isAuthenticatedMode && sessionQuery.error) || boardAccessQuery.error) { return (
{healthQuery.error instanceof Error ? healthQuery.error.message - : boardAccessQuery.error instanceof Error - ? boardAccessQuery.error.message - : "Failed to load app state"} + : sessionQuery.error instanceof Error + ? sessionQuery.error.message + : boardAccessQuery.error instanceof Error + ? boardAccessQuery.error.message + : "Failed to load app state"}
); } + if (isAuthenticatedMode && healthQuery.data?.cloud && !sessionQuery.data) { + return ; + } + if (isBootstrapPending) { const health = healthQuery.data; if (!health) { diff --git a/ui/src/components/CloudSignIn.tsx b/ui/src/components/CloudSignIn.tsx new file mode 100644 index 0000000000..4a7c43d665 --- /dev/null +++ b/ui/src/components/CloudSignIn.tsx @@ -0,0 +1,36 @@ +import { useEffect, useRef, useState } from "react"; +import type { CloudInstanceHealthStatus } from "@/api/health"; +import { cloudStackEntryUrl } from "@/lib/cloudLinks"; +import { beginCloudSignIn, clearCloudSignInAttempt } from "@/lib/cloud-sign-in"; +import { PaperclipLoading } from "@/components/AnimatedPaperclipIcon"; +import { Button } from "@/components/ui/button"; + +export function CloudSignIn({ cloud, returnTo }: { cloud: CloudInstanceHealthStatus; returnTo: string }) { + const entryUrl = cloudStackEntryUrl(cloud.cloudBaseUrl, cloud.stackSlug, returnTo); + const started = useRef(false); + const [blocked, setBlocked] = useState(false); + + useEffect(() => { + if (!entryUrl || started.current) return; + started.current = true; + setBlocked(!beginCloudSignIn(entryUrl)); + }, [entryUrl]); + + if (entryUrl && !blocked) return ; + + return ( +
+

Sign in to Paperclip Cloud

+

+ {entryUrl + ? "We couldn't restore your session. Continue to Paperclip Cloud to try again." + : "Cloud sign-in is unavailable for this workspace. Please contact your administrator."} +

+ {entryUrl && ( + + )} +
+ ); +} diff --git a/ui/src/lib/cloud-sign-in.test.ts b/ui/src/lib/cloud-sign-in.test.ts new file mode 100644 index 0000000000..436c034311 --- /dev/null +++ b/ui/src/lib/cloud-sign-in.test.ts @@ -0,0 +1,36 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { beginCloudSignIn, clearCloudSignInAttempt } from "./cloud-sign-in"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("Cloud sign-in recovery", () => { + it("bounds redirects across documents and resets after verified authentication", () => { + const values = new Map(); + const replace = vi.fn(); + vi.stubGlobal("window", { + location: { replace }, + sessionStorage: { + getItem: (key: string) => values.get(key) ?? null, + setItem: (key: string, value: string) => values.set(key, value), + removeItem: (key: string) => values.delete(key), + }, + }); + expect(beginCloudSignIn("https://cloud.test/entry")).toBe(true); + expect(beginCloudSignIn("https://cloud.test/entry")).toBe(false); + expect(replace).toHaveBeenCalledTimes(1); + clearCloudSignInAttempt(); + expect(beginCloudSignIn("https://cloud.test/entry")).toBe(true); + expect(replace).toHaveBeenCalledTimes(2); + }); + + it("uses manual recovery when tab storage is unavailable", () => { + const replace = vi.fn(); + vi.stubGlobal("window", { + location: { replace }, + get sessionStorage() { throw new Error("Storage disabled"); }, + }); + expect(beginCloudSignIn("https://cloud.test/entry")).toBe(false); + expect(replace).not.toHaveBeenCalled(); + expect(clearCloudSignInAttempt).not.toThrow(); + }); +}); diff --git a/ui/src/lib/cloud-sign-in.ts b/ui/src/lib/cloud-sign-in.ts new file mode 100644 index 0000000000..8d1cb313d4 --- /dev/null +++ b/ui/src/lib/cloud-sign-in.ts @@ -0,0 +1,24 @@ +const RECOVERY_KEY = "paperclip.cloud-sign-in-attempt"; +const RECOVERY_WINDOW_MS = 5 * 60 * 1000; + +export function clearCloudSignInAttempt() { + try { + window.sessionStorage.removeItem(RECOVERY_KEY); + } catch { + // Storage may be disabled. The manual Cloud sign-in link still works. + } +} + +/** One automatic handoff per tab until a session is verified, with a loop guard. */ +export function beginCloudSignIn(url: string): boolean { + try { + const previous = Number(window.sessionStorage.getItem(RECOVERY_KEY)); + if (previous > Date.now() - RECOVERY_WINDOW_MS) return false; + window.sessionStorage.setItem(RECOVERY_KEY, String(Date.now())); + } catch { + // Without durable tab state we cannot safely bound automatic navigation. + return false; + } + window.location.replace(url); + return true; +} diff --git a/ui/src/lib/cloudLinks.test.ts b/ui/src/lib/cloudLinks.test.ts index 9a1657ef6b..f587b4aa24 100644 --- a/ui/src/lib/cloudLinks.test.ts +++ b/ui/src/lib/cloudLinks.test.ts @@ -1,7 +1,27 @@ import { describe, expect, it } from "vitest"; -import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackInviteUrl } from "./cloudLinks"; +import { cloudAppUrl, cloudPortfolioManageUrl, cloudStackCreateUrl, cloudStackInviteUrl, cloudStackEntryUrl, tenantSignInReturnPath } from "./cloudLinks"; describe("cloudLinks", () => { + it.each(["https://my.paperclip.app", "https://my-staging.paperclip.app", "http://cloud.localhost:3200"])( + "renews the session through the configured Cloud origin %s", (origin) => { + const url = new URL(cloudStackEntryUrl(`${origin}/control-plane`, "team", "/TEST/issues/TEST-1?tab=activity#comment")!); + expect(url.origin).toBe(origin); + expect(url.pathname).toBe("/v1/stacks/team/entry-redirect"); + expect(url.searchParams.get("returnTo")).toBe("/TEST/issues/TEST-1?tab=activity#comment"); + }, + ); + + it.each(["https://evil.test", "//evil.test", "/\\evil.test", "/\n/evil.test", "/auth?next=/auth", "/auth/", "/a/../auth"])( + "rejects unsafe or recursive return targets: %j", (path) => { + expect(tenantSignInReturnPath(path)).toBe("/"); + }, + ); + + it("requires complete Cloud metadata", () => { + expect(cloudStackEntryUrl(null, "team", "/")).toBeNull(); + expect(cloudStackEntryUrl("https://my.paperclip.app", null, "/")).toBeNull(); + expect(cloudStackEntryUrl("javascript:alert(1)", "team", "/")).toBeNull(); + }); it("resolves stack links against the cloud origin", () => { expect(cloudStackCreateUrl("https://app.paperclip.app")).toBe( "https://app.paperclip.app/stacks/new", diff --git a/ui/src/lib/cloudLinks.ts b/ui/src/lib/cloudLinks.ts index 68d02f1ed1..7784a493ed 100644 --- a/ui/src/lib/cloudLinks.ts +++ b/ui/src/lib/cloudLinks.ts @@ -30,6 +30,29 @@ export function cloudStackCreateUrl(cloudBaseUrl: string | null | undefined): st return cloudAppUrl(cloudBaseUrl, "/stacks/new"); } +/** Keep auth return targets inside the tenant, and never return to the login form. */ +export function tenantSignInReturnPath(path: string): string { + if (!path.startsWith("/") || path.startsWith("//") || /[\\\x00-\x20]/.test(path)) return "/"; + const url = new URL(path, "https://tenant.invalid"); + if (url.pathname.replace(/\/+$/, "") === "/auth") return "/"; + return `${url.pathname}${url.search}${url.hash}`; +} + +/** Cloud renews a tenant session here, or starts its own sign-in flow. */ +export function cloudStackEntryUrl( + cloudBaseUrl: string | null | undefined, + stackSlug: string | null | undefined, + returnTo: string, +): string | null { + const slug = stackSlug?.trim(); + if (!slug) return null; + const entry = cloudAppUrl(cloudBaseUrl, `/v1/stacks/${encodeURIComponent(slug)}/entry-redirect`); + if (!entry) return null; + const url = new URL(entry); + url.searchParams.set("returnTo", tenantSignInReturnPath(returnTo)); + return url.toString(); +} + /** Cloud manages human invitations in the current stack's People settings. */ export function cloudStackInviteUrl( cloudBaseUrl: string | null | undefined, diff --git a/ui/src/pages/Auth.test.tsx b/ui/src/pages/Auth.test.tsx index 59d70be809..82bcd9a738 100644 --- a/ui/src/pages/Auth.test.tsx +++ b/ui/src/pages/Auth.test.tsx @@ -11,6 +11,14 @@ import { AuthPage } from "./Auth"; const getSessionMock = vi.hoisted(() => vi.fn()); const signInEmailMock = vi.hoisted(() => vi.fn()); const signUpEmailMock = vi.hoisted(() => vi.fn()); +const healthMock = vi.hoisted(() => vi.fn()); +const beginCloudSignInMock = vi.hoisted(() => vi.fn()); + +vi.mock("../api/health", () => ({ healthApi: { get: () => healthMock() } })); +vi.mock("@/lib/cloud-sign-in", () => ({ + beginCloudSignIn: (url: string) => beginCloudSignInMock(url), + clearCloudSignInAttempt: vi.fn(), +})); vi.mock("../api/auth", () => ({ authApi: { @@ -86,6 +94,8 @@ describe("AuthPage", () => { container = document.createElement("div"); document.body.appendChild(container); getSessionMock.mockResolvedValue(null); + healthMock.mockResolvedValue({ status: "ok", deploymentMode: "authenticated" }); + beginCloudSignInMock.mockReturnValue(true); signInEmailMock.mockResolvedValue(undefined); signUpEmailMock.mockResolvedValue(undefined); }); @@ -96,11 +106,11 @@ describe("AuthPage", () => { vi.clearAllMocks(); }); - async function mount() { + async function mount(path = "/auth") { const { root, queryClient } = renderAuthPage(container); await act(async () => { root.render( - + } /> @@ -114,6 +124,52 @@ describe("AuthPage", () => { return { root, queryClient }; } + it.each(["https://my.paperclip.app", "https://my-staging.paperclip.app"])("recovers Cloud auth through %s without rendering an instance form", async (origin) => { + healthMock.mockResolvedValue({ cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: origin, stackSlug: "team" } }); + const { root } = await mount("/auth?next=%2FTEST%2Fissues%2FTEST-1%3Ftab%3Dactivity%23comment"); + await vi.waitFor(() => expect(beginCloudSignInMock).toHaveBeenCalledTimes(1)); + const target = new URL(beginCloudSignInMock.mock.calls[0][0]); + expect(target.origin).toBe(origin); + expect(target.pathname).toBe("/v1/stacks/team/entry-redirect"); + expect(target.searchParams.get("returnTo")).toBe("/TEST/issues/TEST-1?tab=activity#comment"); + expect(container.querySelector("form")).toBeNull(); + await act(() => root.unmount()); + }); + + it("never flashes a form while deployment metadata is loading", async () => { + healthMock.mockReturnValue(new Promise(() => {})); + const { root } = await mount(); + expect(container.querySelector("form")).toBeNull(); + expect(container.textContent).toContain("Loading"); + await act(() => root.unmount()); + }); + + it("fails closed when deployment metadata cannot be loaded", async () => { + healthMock.mockRejectedValue(new Error("offline")); + const { root } = await mount(); + await vi.waitFor(() => expect(container.textContent).toContain("Unable to check sign-in")); + expect(container.querySelector("form")).toBeNull(); + await act(() => root.unmount()); + }); + + it("offers a manual Cloud retry after the automatic recovery limit", async () => { + healthMock.mockResolvedValue({ cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: "https://my.paperclip.app", stackSlug: "team" } }); + beginCloudSignInMock.mockReturnValue(false); + const { root } = await mount(); + await vi.waitFor(() => expect(container.textContent).toContain("Continue to Paperclip Cloud")); + expect(container.querySelector("form")).toBeNull(); + await act(() => root.unmount()); + }); + + it("keeps incomplete Cloud configuration out of the instance form", async () => { + healthMock.mockResolvedValue({ cloud: { managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: null, stackSlug: null } }); + const { root } = await mount(); + await vi.waitFor(() => expect(container.textContent).toContain("Cloud sign-in is unavailable")); + expect(container.querySelector("form")).toBeNull(); + expect(beginCloudSignInMock).not.toHaveBeenCalled(); + await act(() => root.unmount()); + }); + it("exposes password-manager metadata and a11y attributes on the sign-in form", async () => { const { root } = await mount(); @@ -247,7 +303,7 @@ describe("AuthPage", () => { email: "jane@example.com", password: "supersecret", }); - expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true); + expect(healthMock).toHaveBeenCalledTimes(2); await act(async () => { root.unmount(); diff --git a/ui/src/pages/Auth.tsx b/ui/src/pages/Auth.tsx index d16592949d..c391dfd542 100644 --- a/ui/src/pages/Auth.tsx +++ b/ui/src/pages/Auth.tsx @@ -2,6 +2,10 @@ import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useNavigate, useSearchParams } from "@/lib/router"; import { authApi } from "../api/auth"; +import { healthApi } from "../api/health"; +import { CloudSignIn } from "@/components/CloudSignIn"; +import { clearCloudSignInAttempt } from "@/lib/cloud-sign-in"; +import { tenantSignInReturnPath } from "@/lib/cloudLinks"; import { queryKeys } from "../lib/queryKeys"; import { getRememberedInvitePath } from "../lib/invite-memory"; import { Button } from "@/components/ui/button"; @@ -24,10 +28,15 @@ export function AuthPage() { const errorId = "auth-error"; const nextPath = useMemo( - () => searchParams.get("next") || getRememberedInvitePath() || "/", + () => tenantSignInReturnPath(searchParams.get("next") || getRememberedInvitePath() || "/"), [searchParams], ); - const { data: session, isLoading: isSessionLoading } = useQuery({ + const healthQuery = useQuery({ + queryKey: queryKeys.health, + queryFn: () => healthApi.get(), + retry: false, + }); + const { data: session, isLoading: isSessionLoading, error: sessionError } = useQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false, @@ -35,6 +44,7 @@ export function AuthPage() { useEffect(() => { if (session) { + clearCloudSignInAttempt(); navigate(nextPath, { replace: true }); } }, [session, navigate, nextPath]); @@ -72,7 +82,7 @@ export function AuthPage() { password.trim().length > 0 && (mode === "sign_in" || (name.trim().length > 0 && password.trim().length >= 8)); - if (isSessionLoading) { + if (healthQuery.isLoading || isSessionLoading || session) { return (
@@ -80,6 +90,15 @@ export function AuthPage() { ); } + // A health/session failure must not be mistaken for a self-hosted instance. + if (healthQuery.error || sessionError) { + return

Unable to check sign-in. Refresh and try again.

; + } + + if (healthQuery.data?.cloud) { + return ; + } + return (
diff --git a/ui/src/pages/DesignGuide.tsx b/ui/src/pages/DesignGuide.tsx index f5e22e9971..3fa40fbb12 100644 --- a/ui/src/pages/DesignGuide.tsx +++ b/ui/src/pages/DesignGuide.tsx @@ -1,4 +1,5 @@ import { DispositionRecoveryNotice } from "../components/DispositionRecoveryNotice"; +import { CloudSignIn } from "../components/CloudSignIn"; import { SetupPrompt } from "./apps/chat/SetupPrompt"; import { MediaArtifactCard } from "@/components/artifacts/MediaArtifactCard"; import { WebhookUrlWarning } from "@/components/routine-triggers/WebhookUrlWarning"; @@ -2206,6 +2207,10 @@ export function DesignGuide() {

+
+ +
+
{}} loading={false} error={false} /> {}} loading error={false} />