fix(ui): follow managed sign-out redirects
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
2a90933433
commit
76f442040c
|
|
@ -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: "{}",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
export function navigateTopLevel(target: string) {
|
||||
window.location.assign(target);
|
||||
}
|
||||
Loading…
Reference in New Issue