fix(ui): unify cloud-managed sign-out (#10994)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Paperclip can run as a self-hosted app or as a Cloud-managed tenant. > - These modes need different sign-out sequences because Cloud owns three sessions. > - Several visible controls implemented sign-out separately and could choose different paths. > - This pull request adds one Cloud-aware sign-out action and moves every visible control to it. > - The benefit is one safe sign-out path in Cloud and unchanged local sign-out in self-hosted deployments. ## Linked Issues or Issue Description Refs #2073. That older PR adds a separate company-settings sign-out surface. This change centralizes the existing account, company, and instance-settings surfaces and preserves the self-hosted behavior described there. **What happened?** Visible sign-out controls used separate implementations. A Cloud-managed control could call the app-local sign-out endpoint and open the local auth page. That path did not enter the Cloud-owned logout sequence for the tenant, Cloud, and identity sessions. **Expected behavior** Every visible sign-out control must use one action. Cloud-managed instances must navigate the top-level window to the same-origin `/cloud/logout` route without a local sign-out call first. Authenticated self-hosted instances must keep the local sign-out API and cache invalidation behavior. **Steps to reproduce** 1. Open a Cloud-managed tenant. 2. Use the account menu, company menu, or instance-settings sign-out control. 3. Observe that independently implemented controls can enter different sign-out paths. **Paperclip version or commit** The problem reproduces at `656ecfa585b31938e2685ffab3db22e794474803`. **Deployment mode** Cloud-managed tenant built from source. The regression tests also cover authenticated self-hosted mode. ## What Changed - Added `useSignOut` as the shared Cloud-aware sign-out action. - Navigated Cloud-managed sessions to `/cloud/logout` exactly once without calling local auth first. - Preserved local API sign-out and cache invalidation for authenticated self-hosted sessions. - Migrated the account menu, company menu, and instance general settings to the shared action. - Added focused tests for mode selection, menu closure, pending state, failure state, and settings behavior. ## Verification - `pnpm exec vitest run ui/src/hooks/useSignOut.test.tsx ui/src/components/SidebarAccountMenu.test.tsx ui/src/components/SidebarCompanyMenu.test.tsx ui/src/pages/InstanceGeneralSettings.test.tsx` — 26 tests passed. - `pnpm --filter @paperclipai/ui typecheck` — passed. - `pnpm check:token-gates` — passed. - `git diff --check origin/master..HEAD` — passed. ## Risks - Low risk. The change centralizes existing behavior and adds no schema, API, telemetry, or style-token changes. - Cloud mode depends on the existing health decision. Tests pin both mode branches. - The change does not alter Fetch Metadata, CSRF, cookie, prefetch, or return-URL protections owned by the Cloud logout 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, GPT-5. The runtime does not expose a more specific model ID or context-window size. The agent used high-reasoning mode, shell tools, and API tools. ## 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 - [ ] 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:
parent
52b8741b8e
commit
f258b34bbd
|
|
@ -177,6 +177,7 @@ describe("SidebarAccountMenu", () => {
|
|||
|
||||
it("navigates cloud-managed sign-out through the harness without calling local auth", async () => {
|
||||
const root = createRoot(container);
|
||||
const onOpenChange = vi.fn();
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
|
@ -194,7 +195,11 @@ describe("SidebarAccountMenu", () => {
|
|||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu deploymentMode="authenticated" open />
|
||||
<SidebarAccountMenu
|
||||
deploymentMode="authenticated"
|
||||
open
|
||||
onOpenChange={onOpenChange}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
|
|
@ -209,7 +214,31 @@ describe("SidebarAccountMenu", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledOnce();
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps sign-out hidden outside authenticated deployment mode", async () => {
|
||||
const root = createRoot(container);
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<SidebarAccountMenu deploymentMode="local_trusted" open />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(document.body.textContent).not.toContain("Sign out");
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
BookOpen,
|
||||
LogOut,
|
||||
|
|
@ -12,8 +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 { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { useSidebar } from "../context/SidebarContext";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
|
|
@ -26,7 +25,6 @@ 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 {
|
||||
|
|
@ -120,8 +118,6 @@ export function SidebarAccountMenu({
|
|||
version,
|
||||
}: 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,14 +128,7 @@ export function SidebarAccountMenu({
|
|||
retry: false,
|
||||
});
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
|
||||
},
|
||||
});
|
||||
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
|
||||
|
||||
const displayName = session?.user.name?.trim() || "Board";
|
||||
const secondaryLabel =
|
||||
|
|
@ -160,11 +149,6 @@ export function SidebarAccountMenu({
|
|||
}
|
||||
|
||||
function handleSignOut() {
|
||||
if (isCloud) {
|
||||
closeNavigationChrome();
|
||||
navigateTopLevel(MANAGED_SIGN_OUT_PATH);
|
||||
return;
|
||||
}
|
||||
signOutMutation.mutate();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -296,6 +296,9 @@ describe("SidebarCompanyMenu", () => {
|
|||
await flushReact();
|
||||
|
||||
expect(mockAuthApi.signOut).toHaveBeenCalledTimes(1);
|
||||
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
||||
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
|
||||
expect(document.body.textContent).not.toContain("Switch company");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
|
|
@ -456,6 +459,31 @@ describe("SidebarCompanyMenu", () => {
|
|||
});
|
||||
|
||||
describe("in Paperclip Cloud", () => {
|
||||
it("closes the menu and enters the Cloud logout flow without local sign-out", async () => {
|
||||
const { root } = renderMenu({ cloud: true });
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
await openMenu("Open Acme Labs organization switcher");
|
||||
|
||||
const signOutItem = Array.from(document.body.querySelectorAll('[data-slot="dropdown-menu-item"]'))
|
||||
.find((element) => element.textContent?.includes("Sign out"));
|
||||
expect(signOutItem).toBeTruthy();
|
||||
|
||||
act(() => {
|
||||
signOutItem?.dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledOnce();
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
|
||||
expect(document.body.textContent).not.toContain("Switch organization");
|
||||
|
||||
act(() => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("switches organizations instead of companies", async () => {
|
||||
const { root } = renderMenu({ cloud: true });
|
||||
await flushReact();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import {
|
||||
Check,
|
||||
ChevronsUpDown,
|
||||
|
|
@ -37,6 +37,7 @@ import { useCompany } from "@/context/CompanyContext";
|
|||
import { useDialogActions } from "@/context/DialogContext";
|
||||
import { useCloudInstance } from "@/hooks/useCloudInstance";
|
||||
import { useCompanyOrder } from "@/hooks/useCompanyOrder";
|
||||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { cloudStackCreateUrl, cloudStackEnterUrl } from "@/lib/cloudLinks";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
|
|
@ -198,7 +199,6 @@ function SortableCompanyItem({
|
|||
export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: SidebarCompanyMenuProps = {}) {
|
||||
const [internalOpen, setInternalOpen] = useState(false);
|
||||
const [isEditingOrder, setIsEditingOrder] = useState(false);
|
||||
const queryClient = useQueryClient();
|
||||
const { companies, selectedCompany, setSelectedCompanyId } = useCompany();
|
||||
const { openOnboarding } = useDialogActions();
|
||||
const { isMobile, setSidebarOpen, collapsed, peeking } = useSidebar();
|
||||
|
|
@ -257,15 +257,7 @@ export function SidebarCompanyMenu({ open: controlledOpen, onOpenChange }: Sideb
|
|||
? currentStack?.displayName ?? cloud?.stackDisplayName ?? cloud?.stackSlug ?? null
|
||||
: selectedCompany?.name ?? null;
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
onSuccess: async () => {
|
||||
setOpen(false);
|
||||
if (isMobile) setSidebarOpen(false);
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
|
||||
},
|
||||
});
|
||||
const signOutMutation = useSignOut({ onSignedOut: closeNavigationChrome });
|
||||
|
||||
function handleOpenChange(nextOpen: boolean) {
|
||||
if (!nextOpen) setIsEditingOrder(false);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,123 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useSignOut } from "./useSignOut";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({ signOut: vi.fn() }));
|
||||
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({ authApi: mockAuthApi }));
|
||||
vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: mockNavigateTopLevel }));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
let captured: ReturnType<typeof useSignOut> | null = null;
|
||||
|
||||
function Harness({ onSignedOut }: { onSignedOut?: () => void }) {
|
||||
captured = useSignOut({ onSignedOut });
|
||||
return null;
|
||||
}
|
||||
|
||||
describe("useSignOut", () => {
|
||||
let container: HTMLDivElement;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
captured = null;
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
function renderHarness(onSignedOut?: () => void) {
|
||||
const root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Harness onSignedOut={onSignedOut} />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
return root;
|
||||
}
|
||||
|
||||
it("closes caller chrome and navigates through Cloud without local sign-out", async () => {
|
||||
const onSignedOut = vi.fn();
|
||||
queryClient.setQueryData(queryKeys.health, {
|
||||
status: "ok",
|
||||
cloud: {
|
||||
managed: true,
|
||||
managedBy: "paperclip-cloud",
|
||||
stackSlug: "acme",
|
||||
cloudBaseUrl: "https://cloud.example.test",
|
||||
},
|
||||
});
|
||||
const root = renderHarness(onSignedOut);
|
||||
|
||||
flushSync(() => captured?.mutate());
|
||||
|
||||
await vi.waitFor(() => expect(mockNavigateTopLevel).toHaveBeenCalledOnce());
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
|
||||
expect(onSignedOut).toHaveBeenCalledOnce();
|
||||
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("keeps self-hosted sign-out pending until the local request finishes, then invalidates caches", async () => {
|
||||
let resolveSignOut: (() => void) | undefined;
|
||||
mockAuthApi.signOut.mockImplementation(() => new Promise<void>((resolve) => {
|
||||
resolveSignOut = resolve;
|
||||
}));
|
||||
queryClient.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated" });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { session: { id: "session-1" } });
|
||||
const onSignedOut = vi.fn();
|
||||
const root = renderHarness(onSignedOut);
|
||||
|
||||
flushSync(() => captured?.mutate());
|
||||
|
||||
await vi.waitFor(() => expect(captured?.isPending).toBe(true));
|
||||
expect(mockAuthApi.signOut).toHaveBeenCalledOnce();
|
||||
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
||||
expect(onSignedOut).not.toHaveBeenCalled();
|
||||
|
||||
resolveSignOut?.();
|
||||
await vi.waitFor(() => expect(captured?.isPending).toBe(false));
|
||||
|
||||
expect(captured?.error).toBeNull();
|
||||
expect(onSignedOut).toHaveBeenCalledOnce();
|
||||
expect(queryClient.getQueryState(queryKeys.auth.session)?.isInvalidated).toBe(true);
|
||||
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(true);
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
|
||||
it("exposes a stable error without closing chrome or invalidating caches", async () => {
|
||||
mockAuthApi.signOut.mockRejectedValue(new Error("Sign-out request failed"));
|
||||
queryClient.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated" });
|
||||
queryClient.setQueryData(queryKeys.auth.session, { session: { id: "session-1" } });
|
||||
const onSignedOut = vi.fn();
|
||||
const root = renderHarness(onSignedOut);
|
||||
|
||||
flushSync(() => captured?.mutate());
|
||||
|
||||
await vi.waitFor(() => expect(captured?.error?.message).toBe("Sign-out request failed"));
|
||||
expect(captured?.isPending).toBe(false);
|
||||
expect(onSignedOut).not.toHaveBeenCalled();
|
||||
expect(queryClient.getQueryState(queryKeys.auth.session)?.isInvalidated).toBe(false);
|
||||
expect(queryClient.getQueryState(queryKeys.health)?.isInvalidated).toBe(false);
|
||||
|
||||
flushSync(() => root.unmount());
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { useCloudInstance } from "./useCloudInstance";
|
||||
|
||||
const CLOUD_SIGN_OUT_PATH = "/cloud/logout";
|
||||
|
||||
interface UseSignOutOptions {
|
||||
onSignedOut?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Owns the app-wide sign-out decision.
|
||||
*
|
||||
* Cloud-managed tenants must enter the harness-owned logout sequence without
|
||||
* first clearing the tenant session. Authenticated self-hosted instances keep
|
||||
* the local API flow and invalidate the auth-dependent caches afterward.
|
||||
*/
|
||||
export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) {
|
||||
const cloud = useCloudInstance();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: async () => {
|
||||
if (cloud) {
|
||||
onSignedOut?.();
|
||||
navigateTopLevel(CLOUD_SIGN_OUT_PATH);
|
||||
return "cloud" as const;
|
||||
}
|
||||
|
||||
await authApi.signOut();
|
||||
return "self-hosted" as const;
|
||||
},
|
||||
onSuccess: async (target) => {
|
||||
if (target === "cloud") return;
|
||||
|
||||
onSignedOut?.();
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.auth.session }),
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.health }),
|
||||
]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
// @vitest-environment jsdom
|
||||
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { InstanceGeneralSettings } from "./InstanceGeneralSettings";
|
||||
|
||||
const mockAuthApi = vi.hoisted(() => ({ signOut: vi.fn() }));
|
||||
const mockHealthApi = vi.hoisted(() => ({ get: vi.fn() }));
|
||||
const mockInstanceSettingsApi = vi.hoisted(() => ({
|
||||
getGeneral: vi.fn(),
|
||||
updateGeneral: vi.fn(),
|
||||
}));
|
||||
const mockNavigateTopLevel = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/auth", () => ({ authApi: mockAuthApi }));
|
||||
vi.mock("@/api/health", () => ({ healthApi: mockHealthApi }));
|
||||
vi.mock("@/api/instanceSettings", () => ({ instanceSettingsApi: mockInstanceSettingsApi }));
|
||||
vi.mock("@/lib/browserNavigation", () => ({ navigateTopLevel: mockNavigateTopLevel }));
|
||||
vi.mock("../context/BreadcrumbContext", () => ({
|
||||
useBreadcrumbs: () => ({ setBreadcrumbs: vi.fn() }),
|
||||
}));
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const SELF_HOSTED_HEALTH = {
|
||||
status: "ok" as const,
|
||||
deploymentMode: "authenticated" as const,
|
||||
deploymentExposure: "private" as const,
|
||||
authReady: true,
|
||||
bootstrapStatus: "ready" as const,
|
||||
bootstrapInviteActive: false,
|
||||
};
|
||||
|
||||
const CLOUD_HEALTH = {
|
||||
...SELF_HOSTED_HEALTH,
|
||||
cloud: {
|
||||
managed: true as const,
|
||||
managedBy: "paperclip-cloud" as const,
|
||||
stackSlug: "acme",
|
||||
cloudBaseUrl: "https://cloud.example.test",
|
||||
},
|
||||
};
|
||||
|
||||
describe("InstanceGeneralSettings sign-out", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root | null;
|
||||
let queryClient: QueryClient;
|
||||
|
||||
beforeEach(() => {
|
||||
container = document.createElement("div");
|
||||
document.body.appendChild(container);
|
||||
root = null;
|
||||
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
mockInstanceSettingsApi.getGeneral.mockResolvedValue({
|
||||
censorUsernameInLogs: false,
|
||||
keyboardShortcuts: false,
|
||||
feedbackDataSharingPreference: "not_allowed",
|
||||
backupRetention: { dailyDays: 7, weeklyWeeks: 4, monthlyMonths: 1 },
|
||||
});
|
||||
mockInstanceSettingsApi.updateGeneral.mockResolvedValue(undefined);
|
||||
mockAuthApi.signOut.mockResolvedValue({ success: true });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
flushSync(() => root?.unmount());
|
||||
queryClient.clear();
|
||||
container.remove();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
async function renderPage(health: typeof SELF_HOSTED_HEALTH | typeof CLOUD_HEALTH) {
|
||||
mockHealthApi.get.mockResolvedValue(health);
|
||||
queryClient.setQueryData(queryKeys.health, health);
|
||||
root = createRoot(container);
|
||||
flushSync(() => {
|
||||
root?.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<InstanceGeneralSettings />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Deployment and auth"));
|
||||
}
|
||||
|
||||
function signOutButton() {
|
||||
return Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Sign out");
|
||||
}
|
||||
|
||||
it("uses the Cloud-managed top-level logout without calling local auth", async () => {
|
||||
await renderPage(CLOUD_HEALTH);
|
||||
|
||||
flushSync(() => signOutButton()?.click());
|
||||
|
||||
await vi.waitFor(() => expect(mockNavigateTopLevel).toHaveBeenCalledOnce());
|
||||
expect(mockNavigateTopLevel).toHaveBeenCalledWith("/cloud/logout");
|
||||
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps authenticated self-hosted sign-out local and invalidates auth caches", async () => {
|
||||
const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries");
|
||||
await renderPage(SELF_HOSTED_HEALTH);
|
||||
|
||||
flushSync(() => signOutButton()?.click());
|
||||
|
||||
await vi.waitFor(() => expect(mockAuthApi.signOut).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: queryKeys.auth.session,
|
||||
}));
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: queryKeys.health });
|
||||
expect(mockNavigateTopLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows a current sign-out failure instead of a stale settings error", async () => {
|
||||
mockInstanceSettingsApi.updateGeneral.mockRejectedValue(new Error("Settings update failed"));
|
||||
mockAuthApi.signOut.mockRejectedValue(new Error("Sign-out request failed"));
|
||||
await renderPage(SELF_HOSTED_HEALTH);
|
||||
|
||||
const keyboardToggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Toggle keyboard shortcuts"]',
|
||||
);
|
||||
flushSync(() => keyboardToggle?.click());
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Settings update failed"));
|
||||
|
||||
flushSync(() => signOutButton()?.click());
|
||||
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Sign-out request failed"));
|
||||
expect(container.textContent).not.toContain("Settings update failed");
|
||||
});
|
||||
|
||||
it("clears a stale sign-out failure after a settings update succeeds", async () => {
|
||||
mockAuthApi.signOut.mockRejectedValue(new Error("Sign-out request failed"));
|
||||
await renderPage(SELF_HOSTED_HEALTH);
|
||||
|
||||
flushSync(() => signOutButton()?.click());
|
||||
await vi.waitFor(() => expect(container.textContent).toContain("Sign-out request failed"));
|
||||
|
||||
const keyboardToggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Toggle keyboard shortcuts"]',
|
||||
);
|
||||
flushSync(() => keyboardToggle?.click());
|
||||
|
||||
await vi.waitFor(() => expect(mockInstanceSettingsApi.updateGeneral).toHaveBeenCalledOnce());
|
||||
await vi.waitFor(() => expect(container.textContent).not.toContain("Sign-out request failed"));
|
||||
});
|
||||
|
||||
it("disables settings changes while sign-out is pending", async () => {
|
||||
let resolveSignOut: ((result: { success: boolean }) => void) | undefined;
|
||||
mockAuthApi.signOut.mockImplementation(
|
||||
() => new Promise<{ success: boolean }>((resolve) => {
|
||||
resolveSignOut = resolve;
|
||||
}),
|
||||
);
|
||||
await renderPage(SELF_HOSTED_HEALTH);
|
||||
|
||||
const keyboardToggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Toggle keyboard shortcuts"]',
|
||||
);
|
||||
flushSync(() => signOutButton()?.click());
|
||||
await vi.waitFor(() => expect(mockAuthApi.signOut).toHaveBeenCalledOnce());
|
||||
|
||||
expect(keyboardToggle?.disabled).toBe(true);
|
||||
flushSync(() => keyboardToggle?.click());
|
||||
expect(mockInstanceSettingsApi.updateGeneral).not.toHaveBeenCalled();
|
||||
|
||||
resolveSignOut?.({ success: true });
|
||||
await vi.waitFor(() => expect(keyboardToggle?.disabled).toBe(false));
|
||||
});
|
||||
|
||||
it("disables sign-out while a settings update is pending", async () => {
|
||||
let resolveSettings: (() => void) | undefined;
|
||||
mockInstanceSettingsApi.updateGeneral.mockImplementation(
|
||||
() => new Promise<void>((resolve) => {
|
||||
resolveSettings = resolve;
|
||||
}),
|
||||
);
|
||||
await renderPage(SELF_HOSTED_HEALTH);
|
||||
|
||||
const keyboardToggle = container.querySelector<HTMLButtonElement>(
|
||||
'[aria-label="Toggle keyboard shortcuts"]',
|
||||
);
|
||||
flushSync(() => keyboardToggle?.click());
|
||||
await vi.waitFor(() => expect(mockInstanceSettingsApi.updateGeneral).toHaveBeenCalledOnce());
|
||||
|
||||
expect(signOutButton()?.disabled).toBe(true);
|
||||
flushSync(() => signOutButton()?.click());
|
||||
expect(mockAuthApi.signOut).not.toHaveBeenCalled();
|
||||
|
||||
resolveSettings?.();
|
||||
await vi.waitFor(() => expect(signOutButton()?.disabled).toBe(false));
|
||||
});
|
||||
});
|
||||
|
|
@ -8,7 +8,6 @@ import {
|
|||
DEFAULT_BACKUP_RETENTION,
|
||||
} from "@paperclipai/shared";
|
||||
import { LogOut, SlidersHorizontal } from "lucide-react";
|
||||
import { authApi } from "@/api/auth";
|
||||
import { healthApi } from "@/api/health";
|
||||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||||
import { ModeBadge } from "@/components/access/ModeBadge";
|
||||
|
|
@ -18,6 +17,7 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { ToggleSwitch } from "@/components/ui/toggle-switch";
|
||||
import { cn } from "../lib/utils";
|
||||
import { useSignOut } from "@/hooks/useSignOut";
|
||||
|
||||
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
||||
|
||||
|
|
@ -26,16 +26,7 @@ export function InstanceGeneralSettings() {
|
|||
const queryClient = useQueryClient();
|
||||
const [actionError, setActionError] = useState<string | null>(null);
|
||||
|
||||
const signOutMutation = useMutation({
|
||||
mutationFn: () => authApi.signOut(),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.auth.session });
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.health });
|
||||
},
|
||||
onError: (error) => {
|
||||
setActionError(error instanceof Error ? error.message : "Failed to sign out.");
|
||||
},
|
||||
});
|
||||
const signOutMutation = useSignOut();
|
||||
|
||||
useEffect(() => {
|
||||
setBreadcrumbs([
|
||||
|
|
@ -57,8 +48,13 @@ export function InstanceGeneralSettings() {
|
|||
|
||||
const updateGeneralMutation = useMutation({
|
||||
mutationFn: instanceSettingsApi.updateGeneral,
|
||||
onMutate: () => {
|
||||
setActionError(null);
|
||||
signOutMutation.reset();
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setActionError(null);
|
||||
signOutMutation.reset();
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.instance.generalSettings });
|
||||
},
|
||||
onError: (error) => {
|
||||
|
|
@ -84,6 +80,11 @@ export function InstanceGeneralSettings() {
|
|||
const keyboardShortcuts = generalQuery.data?.keyboardShortcuts === true;
|
||||
const feedbackDataSharingPreference = generalQuery.data?.feedbackDataSharingPreference ?? "prompt";
|
||||
const backupRetention: BackupRetentionPolicy = generalQuery.data?.backupRetention ?? DEFAULT_BACKUP_RETENTION;
|
||||
const visibleActionError = signOutMutation.error instanceof Error
|
||||
? signOutMutation.error.message
|
||||
: signOutMutation.error
|
||||
? "Failed to sign out."
|
||||
: actionError;
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl space-y-6">
|
||||
|
|
@ -98,9 +99,9 @@ export function InstanceGeneralSettings() {
|
|||
</p>
|
||||
</div>
|
||||
|
||||
{actionError && (
|
||||
{visibleActionError && (
|
||||
<div className="rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-sm text-destructive">
|
||||
{actionError}
|
||||
{visibleActionError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
@ -150,7 +151,7 @@ export function InstanceGeneralSettings() {
|
|||
<ToggleSwitch
|
||||
checked={censorUsernameInLogs}
|
||||
onCheckedChange={() => updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })}
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
disabled={updateGeneralMutation.isPending || signOutMutation.isPending}
|
||||
aria-label="Toggle username log censoring"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -168,7 +169,7 @@ export function InstanceGeneralSettings() {
|
|||
<ToggleSwitch
|
||||
checked={keyboardShortcuts}
|
||||
onCheckedChange={() => updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })}
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
disabled={updateGeneralMutation.isPending || signOutMutation.isPending}
|
||||
aria-label="Toggle keyboard shortcuts"
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -194,7 +195,7 @@ export function InstanceGeneralSettings() {
|
|||
<button
|
||||
key={days}
|
||||
type="button"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
disabled={updateGeneralMutation.isPending || signOutMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
|
|
@ -224,7 +225,7 @@ export function InstanceGeneralSettings() {
|
|||
<button
|
||||
key={weeks}
|
||||
type="button"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
disabled={updateGeneralMutation.isPending || signOutMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
|
|
@ -254,7 +255,7 @@ export function InstanceGeneralSettings() {
|
|||
<button
|
||||
key={months}
|
||||
type="button"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
disabled={updateGeneralMutation.isPending || signOutMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
|
|
@ -319,7 +320,7 @@ export function InstanceGeneralSettings() {
|
|||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
disabled={updateGeneralMutation.isPending}
|
||||
disabled={updateGeneralMutation.isPending || signOutMutation.isPending}
|
||||
className={cn(
|
||||
"rounded-lg border px-3 py-2 text-left transition-colors disabled:cursor-not-allowed disabled:opacity-60",
|
||||
active
|
||||
|
|
@ -363,8 +364,11 @@ export function InstanceGeneralSettings() {
|
|||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={signOutMutation.isPending}
|
||||
onClick={() => signOutMutation.mutate()}
|
||||
disabled={signOutMutation.isPending || updateGeneralMutation.isPending}
|
||||
onClick={() => {
|
||||
setActionError(null);
|
||||
signOutMutation.mutate();
|
||||
}}
|
||||
>
|
||||
<LogOut className="size-4" />
|
||||
{signOutMutation.isPending ? "Signing out..." : "Sign out"}
|
||||
|
|
|
|||
Loading…
Reference in New Issue