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:
Dotta 2026-08-06 12:13:14 -05:00 committed by GitHub
parent 52b8741b8e
commit f258b34bbd
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 453 additions and 52 deletions

View File

@ -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();

View File

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

View File

@ -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();

View File

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

View File

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

View File

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

View File

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

View File

@ -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"}