fix(ui): use cloud logout for managed sign-out (#10937)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-05 21:14:11 -05:00 committed by GitHub
parent f5e9ca3e89
commit ac3b2e1d7a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 28 additions and 10 deletions

View File

@ -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 () => {

View File

@ -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 (
<div className="border-t border-r border-border bg-background px-3 py-2">
<Popover open={open} onOpenChange={setOpen}>
@ -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}
>
<span className="mt-0.5 rounded-lg border border-border bg-background/70 p-2 text-muted-foreground">