From 76f442040ccbae68e82bdf7ada67c9ea6c64ba7d Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Mon, 3 Aug 2026 22:34:14 -0500 Subject: [PATCH] fix(ui): follow managed sign-out redirects Co-Authored-By: Paperclip --- ui/src/api/auth.test.ts | 29 ++++++++++++++ ui/src/api/auth.ts | 18 +++++++-- ui/src/components/SidebarAccountMenu.test.tsx | 40 ++++++++++++++++++- ui/src/components/SidebarAccountMenu.tsx | 8 +++- ui/src/lib/browserNavigation.ts | 3 ++ 5 files changed, 92 insertions(+), 6 deletions(-) create mode 100644 ui/src/api/auth.test.ts create mode 100644 ui/src/lib/browserNavigation.ts diff --git a/ui/src/api/auth.test.ts b/ui/src/api/auth.test.ts new file mode 100644 index 0000000000..5f05d928ae --- /dev/null +++ b/ui/src/api/auth.test.ts @@ -0,0 +1,29 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { authApi } from "./auth"; + +describe("authApi.signOut", () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("returns the managed deployment redirect from the response", async () => { + const fetchMock = vi.fn().mockResolvedValue( + new Response(JSON.stringify({ success: true, redirectTo: "/cloud/logout" }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); + vi.stubGlobal("fetch", fetchMock); + + await expect(authApi.signOut()).resolves.toEqual({ + success: true, + redirectTo: "/cloud/logout", + }); + expect(fetchMock).toHaveBeenCalledWith("/api/auth/sign-out", { + method: "POST", + credentials: "include", + headers: { "Content-Type": "application/json" }, + body: "{}", + }); + }); +}); diff --git a/ui/src/api/auth.ts b/ui/src/api/auth.ts index 8c59e37d96..5bf530d5d7 100644 --- a/ui/src/api/auth.ts +++ b/ui/src/api/auth.ts @@ -15,6 +15,11 @@ type AuthErrorBody = } | null; +export interface SignOutResult { + success?: boolean; + redirectTo?: string; +} + export class AuthApiError extends Error { status: number; code: string | null; @@ -105,7 +110,7 @@ function logAuthHttpError(method: string, path: string, status: number, statusTe }); } -async function authPost(path: string, body: Record) { +async function authPost(path: string, body: Record): Promise { let res: Response; try { res = await fetch(`/api/auth${path}`, { @@ -180,7 +185,14 @@ export const authApi = { updateProfile: async (input: UpdateCurrentUserProfile): Promise => authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)), - signOut: async () => { - await authPost("/sign-out", {}); + signOut: async (): Promise => { + const payload = await authPost("/sign-out", {}); + if (!payload || typeof payload !== "object") return null; + + const result = payload as Record; + return { + ...(typeof result.success === "boolean" ? { success: result.success } : {}), + ...(typeof result.redirectTo === "string" ? { redirectTo: result.redirectTo } : {}), + }; }, }; diff --git a/ui/src/components/SidebarAccountMenu.test.tsx b/ui/src/components/SidebarAccountMenu.test.tsx index bf36a3e5b4..5935f1a6ef 100644 --- a/ui/src/components/SidebarAccountMenu.test.tsx +++ b/ui/src/components/SidebarAccountMenu.test.tsx @@ -19,11 +19,16 @@ const mockInstanceSettingsApi = vi.hoisted(() => ({ })); const mockToggleTheme = vi.hoisted(() => vi.fn()); const mockSetSidebarOpen = vi.hoisted(() => vi.fn()); +const mockNavigateTopLevel = vi.hoisted(() => vi.fn()); vi.mock("@/api/auth", () => ({ authApi: mockAuthApi, })); +vi.mock("@/lib/browserNavigation", () => ({ + navigateTopLevel: mockNavigateTopLevel, +})); + vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi, })); @@ -86,7 +91,7 @@ describe("SidebarAccountMenu", () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, }); - mockAuthApi.signOut.mockResolvedValue(undefined); + mockAuthApi.signOut.mockResolvedValue({ success: true, redirectTo: "/cloud/logout" }); }); afterEach(() => { @@ -162,7 +167,38 @@ describe("SidebarAccountMenu", () => { await flushReact(); expect(mockAuthApi.signOut).toHaveBeenCalledOnce(); - expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true); + expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout"); + + 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 }); + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + + await act(async () => { + root.render( + + + , + ); + }); + await flushReact(); + + const signOutButton = Array.from(document.body.querySelectorAll("button")).find( + (button) => button.textContent?.includes("Sign out"), + ); + await act(async () => { + signOutButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + await flushReact(); + + expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout"); await act(async () => { root.unmount(); diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index f2dd6d439a..368888bf1e 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -12,6 +12,7 @@ import type { DeploymentMode, ServerGitInfo } from "@paperclipai/shared"; import { Link } from "@/lib/router"; import { authApi } from "@/api/auth"; import { queryKeys } from "@/lib/queryKeys"; +import { navigateTopLevel } from "@/lib/browserNavigation"; import { useSidebar } from "../context/SidebarContext"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; @@ -24,6 +25,7 @@ const PROFILE_SETTINGS_PATH = "/company/settings/instance/profile"; const DOCS_URL = "https://docs.paperclip.ing/"; const FEEDBACK_URL = "https://paperclip.ing/feedback"; const SOURCE_REPOSITORY_URL = "https://github.com/paperclipai/paperclip"; +const MANAGED_SIGN_OUT_PATH = "/cloud/logout"; const SOURCE_VERSION_RE = /\+\d+\.git\.([0-9a-f]{7,40})(?:\.dirty)?$/i; interface SidebarAccountMenuProps { @@ -130,8 +132,12 @@ export function SidebarAccountMenu({ const signOutMutation = useMutation({ mutationFn: () => authApi.signOut(), - onSuccess: async () => { + onSuccess: async (result) => { 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 }); }, diff --git a/ui/src/lib/browserNavigation.ts b/ui/src/lib/browserNavigation.ts new file mode 100644 index 0000000000..0828518295 --- /dev/null +++ b/ui/src/lib/browserNavigation.ts @@ -0,0 +1,3 @@ +export function navigateTopLevel(target: string) { + window.location.assign(target); +}