fix(ui): follow managed sign-out redirects

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-08-03 22:34:14 -05:00 committed by GitHub
parent 2a90933433
commit 76f442040c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 92 additions and 6 deletions

29
ui/src/api/auth.test.ts Normal file
View File

@ -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: "{}",
});
});
});

View File

@ -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<string, unknown>) {
async function authPost(path: string, body: Record<string, unknown>): Promise<unknown> {
let res: Response;
try {
res = await fetch(`/api/auth${path}`, {
@ -180,7 +185,14 @@ export const authApi = {
updateProfile: async (input: UpdateCurrentUserProfile): Promise<CurrentUserProfile> =>
authPatch("/profile", input, (payload) => currentUserProfileSchema.parse(payload)),
signOut: async () => {
await authPost("/sign-out", {});
signOut: async (): Promise<SignOutResult | null> => {
const payload = await authPost("/sign-out", {});
if (!payload || typeof payload !== "object") return null;
const result = payload as Record<string, unknown>;
return {
...(typeof result.success === "boolean" ? { success: result.success } : {}),
...(typeof result.redirectTo === "string" ? { redirectTo: result.redirectTo } : {}),
};
},
};

View File

@ -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(
<QueryClientProvider client={queryClient}>
<SidebarAccountMenu deploymentMode="authenticated" open />
</QueryClientProvider>,
);
});
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();

View File

@ -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 });
},

View File

@ -0,0 +1,3 @@
export function navigateTopLevel(target: string) {
window.location.assign(target);
}