mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-12 08:19:54 +02:00
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:
1 parent
270afd2fb8
commit
d9d2147171
12 files changed
+371
-13
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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
@@ -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" },
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in new issue
Block a user