From ac3b2e1d7a70b17daa8e3b9576577794e8e80893 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:14:11 -0500 Subject: [PATCH] fix(ui): use cloud logout for managed sign-out (#10937) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Paperclip supports self-hosted and Cloud-managed authenticated deployments > - A Cloud-managed tenant uses the Cloud harness to own the full browser session > - The account menu treated every authenticated deployment as Cloud and called the local sign-out API first > - This pull request uses the existing Cloud health metadata as the mode gate > - Cloud-managed sign-out now starts the harness logout round trip with a top-level navigation > - Self-hosted authenticated sign-out keeps the existing local API flow > - The benefit is a complete Cloud logout without changing self-hosted behavior ## Linked Issues or Issue Description Related prior work: Refs #10802. **What happened?** The account menu called the app-local sign-out endpoint before it moved an authenticated browser to the Cloud logout route. It also used authenticated deployment mode as the Cloud test. This test included self-hosted authenticated instances. **Expected behavior** A Cloud-managed tenant must navigate the top-level browser directly to `/cloud/logout`. A self-hosted authenticated instance must keep the app-local sign-out flow. **Steps to reproduce** 1. Open a Cloud-managed tenant. 2. Open the account menu. 3. Select **Sign out**. 4. Observe that the browser returns through the tenant auth route instead of completing the Cloud logout round trip. **Paperclip version or commit** Reproduced on `master` after `76f442040c`. **Deployment mode** Paperclip Cloud-managed authenticated deployment. ## What Changed - Read the existing Cloud instance metadata in the account menu. - Navigate directly to `/cloud/logout` for Cloud-managed instances without calling the local sign-out API. - Keep the local sign-out API and cache refresh for self-hosted authenticated instances. - Add regression coverage for both sides of the mode gate. ## Verification - `pnpm exec vitest run ui/src/components/SidebarAccountMenu.test.tsx` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm check:token-gates` ## Risks - Low risk. The change is limited to the account-menu action. - The Cloud branch depends on the existing `health.cloud` metadata that already gates other Cloud UI behavior. - The self-hosted regression test verifies that authenticated mode alone does not select the Cloud route. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex based on GPT-5. The runtime provided agentic reasoning, repository tools, shell execution, and test execution. The exact internal model ID and context window are not exposed to the agent. ## 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 #` / `Refs #` 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 --- ui/src/components/SidebarAccountMenu.test.tsx | 19 +++++++++++++++---- ui/src/components/SidebarAccountMenu.tsx | 19 +++++++++++++------ 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/ui/src/components/SidebarAccountMenu.test.tsx b/ui/src/components/SidebarAccountMenu.test.tsx index 5935f1a6ef..f596c625d1 100644 --- a/ui/src/components/SidebarAccountMenu.test.tsx +++ b/ui/src/components/SidebarAccountMenu.test.tsx @@ -100,7 +100,7 @@ describe("SidebarAccountMenu", () => { vi.clearAllMocks(); }); - it("renders the signed-in user and opens the account card menu", async () => { + it("keeps authenticated self-hosted sign-out on the local auth flow", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -167,19 +167,29 @@ describe("SidebarAccountMenu", () => { await flushReact(); expect(mockAuthApi.signOut).toHaveBeenCalledOnce(); - expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout"); + expect(mockNavigateTopLevel).not.toHaveBeenCalled(); + expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true); await act(async () => { root.unmount(); }); }); - it("falls back to the managed logout route when sign-out omits a redirect", async () => { - mockAuthApi.signOut.mockResolvedValue({ success: true }); + it("navigates cloud-managed sign-out through the harness without calling local auth", async () => { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, }); + queryClient.setQueryData(queryKeys.health, { + status: "ok", + deploymentMode: "authenticated", + cloud: { + managed: true, + managedBy: "paperclip-cloud", + stackSlug: "acme-labs", + cloudBaseUrl: "https://cloud.example.test", + }, + }); await act(async () => { root.render( @@ -198,6 +208,7 @@ describe("SidebarAccountMenu", () => { }); await flushReact(); + expect(mockAuthApi.signOut).not.toHaveBeenCalled(); expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout"); await act(async () => { diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index 368888bf1e..8713b12de1 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -13,6 +13,7 @@ import { Link } from "@/lib/router"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; import { navigateTopLevel } from "@/lib/browserNavigation"; +import { useCloudInstance } from "@/hooks/useCloudInstance"; import { useSidebar } from "../context/SidebarContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -120,6 +121,7 @@ export function SidebarAccountMenu({ }: SidebarAccountMenuProps) { const [internalOpen, setInternalOpen] = useState(false); const queryClient = useQueryClient(); + const isCloud = Boolean(useCloudInstance()); const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar(); const rail = collapsed && !peeking; const open = controlledOpen ?? internalOpen; @@ -132,12 +134,8 @@ export function SidebarAccountMenu({ const signOutMutation = useMutation({ mutationFn: () => authApi.signOut(), - onSuccess: async (result) => { + onSuccess: async () => { setOpen(false); - if (deploymentMode === "authenticated") { - navigateTopLevel(result?.redirectTo?.trim() || MANAGED_SIGN_OUT_PATH); - return; - } await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }); await queryClient.invalidateQueries({ queryKey: queryKeys.health }); }, @@ -161,6 +159,15 @@ export function SidebarAccountMenu({ if (isMobile) setSidebarOpen(false); } + function handleSignOut() { + if (isCloud) { + closeNavigationChrome(); + navigateTopLevel(MANAGED_SIGN_OUT_PATH); + return; + } + signOutMutation.mutate(); + } + return (
@@ -269,7 +276,7 @@ export function SidebarAccountMenu({ "flex w-full items-start gap-3 rounded-xl px-3 py-3 text-left transition-colors hover:bg-destructive/10", signOutMutation.isPending && "cursor-not-allowed opacity-60", )} - onClick={() => signOutMutation.mutate()} + onClick={handleSignOut} disabled={signOutMutation.isPending} >