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 <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-28 13:45:27 -05:00
1 parent 270afd2fb8
commit d9d2147171
12 files changed
+371 -13

No files matched your search

+21
View File
@@ -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:
+64
View File
@@ -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<void> }).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();
});
+43 -2
View File
@@ -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" },
+17 -4
View File
@@ -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 <PaperclipLoading />;
}
if (healthQuery.error || boardAccessQuery.error) {
if (healthQuery.error || (isAuthenticatedMode && sessionQuery.error) || boardAccessQuery.error) {
return (
<div className="mx-auto max-w-xl py-10 text-sm text-destructive">
{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"}
</div>
);
}
if (isAuthenticatedMode && healthQuery.data?.cloud && !sessionQuery.data) {
return <CloudSignIn cloud={healthQuery.data.cloud} returnTo={`${location.pathname}${location.search}${location.hash}`} />;
}
if (isBootstrapPending) {
const health = healthQuery.data;
if (!health) {
+36
View File
@@ -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 <PaperclipLoading />;
return (
<div className="mx-auto max-w-xl space-y-4 p-6">
<h1 className="text-xl font-semibold">Sign in to Paperclip Cloud</h1>
<p role="alert" className="text-sm text-muted-foreground">
{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."}
</p>
{entryUrl && (
<Button asChild>
<a href={entryUrl} onClick={clearCloudSignInAttempt}>Continue to Paperclip Cloud</a>
</Button>
)}
</div>
);
}
+36
View File
@@ -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<string, string>();
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();
});
});
+24
View File
@@ -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;
}
+21 -1
View File
@@ -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",
+23
View File
@@ -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,
+59 -3
View File
@@ -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(
<MemoryRouter initialEntries={["/auth"]}>
<MemoryRouter initialEntries={[path]}>
<QueryClientProvider client={queryClient}>
<Routes>
<Route path="/auth" element={<AuthPage />} />
@@ -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();
+22 -3
View File
@@ -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 (
<div className="fixed inset-0 flex items-center justify-center">
<PaperclipLoading className="min-h-0" />
@@ -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 <p role="alert" className="p-6 text-sm text-destructive">Unable to check sign-in. Refresh and try again.</p>;
}
if (healthQuery.data?.cloud) {
return <CloudSignIn cloud={healthQuery.data.cloud} returnTo={nextPath} />;
}
return (
<div className="fixed inset-0 flex bg-background">
<div className="absolute top-4 right-4 z-10">
+5
View File
@@ -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() {
</p>
</Section>
<Section title="Cloud sign-in unavailable">
<CloudSignIn cloud={{ managed: true, managedBy: "paperclip-cloud", cloudBaseUrl: null, stackSlug: null }} returnTo="/" />
</Section>
<Section title="Saved provider API keys">
<SavedProviderKeySelect options={[{ id: "example", label: "Claude API key (Your key)", binding: { type: "user_secret_ref", key: "ANTHROPIC_API_KEY", version: "latest" } }]} value="example" onChange={() => {}} loading={false} error={false} />
<SavedProviderKeySelect options={[]} value="" onChange={() => {}} loading error={false} />