diff --git a/ui/src/components/SidebarAccountMenu.test.tsx b/ui/src/components/SidebarAccountMenu.test.tsx index f596c625d1..de2da8d8ea 100644 --- a/ui/src/components/SidebarAccountMenu.test.tsx +++ b/ui/src/components/SidebarAccountMenu.test.tsx @@ -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( - + , ); }); @@ -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( + + + , + ); + }); + await flushReact(); + + expect(document.body.textContent).not.toContain("Sign out"); await act(async () => { root.unmount(); diff --git a/ui/src/components/SidebarAccountMenu.tsx b/ui/src/components/SidebarAccountMenu.tsx index 8713b12de1..be58276d8a 100644 --- a/ui/src/components/SidebarAccountMenu.tsx +++ b/ui/src/components/SidebarAccountMenu.tsx @@ -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(); } diff --git a/ui/src/components/SidebarCompanyMenu.test.tsx b/ui/src/components/SidebarCompanyMenu.test.tsx index c1c925d25e..27b2a7a583 100644 --- a/ui/src/components/SidebarCompanyMenu.test.tsx +++ b/ui/src/components/SidebarCompanyMenu.test.tsx @@ -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(); diff --git a/ui/src/components/SidebarCompanyMenu.tsx b/ui/src/components/SidebarCompanyMenu.tsx index 928b6e0bac..714c050fae 100644 --- a/ui/src/components/SidebarCompanyMenu.tsx +++ b/ui/src/components/SidebarCompanyMenu.tsx @@ -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); diff --git a/ui/src/hooks/useSignOut.test.tsx b/ui/src/hooks/useSignOut.test.tsx new file mode 100644 index 0000000000..0745ca1187 --- /dev/null +++ b/ui/src/hooks/useSignOut.test.tsx @@ -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 | 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( + + + , + ); + }); + 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((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()); + }); +}); diff --git a/ui/src/hooks/useSignOut.ts b/ui/src/hooks/useSignOut.ts new file mode 100644 index 0000000000..0d91d66a98 --- /dev/null +++ b/ui/src/hooks/useSignOut.ts @@ -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 }), + ]); + }, + }); +} diff --git a/ui/src/pages/InstanceGeneralSettings.test.tsx b/ui/src/pages/InstanceGeneralSettings.test.tsx new file mode 100644 index 0000000000..9eff02c7a1 --- /dev/null +++ b/ui/src/pages/InstanceGeneralSettings.test.tsx @@ -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( + + + , + ); + }); + 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( + '[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( + '[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( + '[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((resolve) => { + resolveSettings = resolve; + }), + ); + await renderPage(SELF_HOSTED_HEALTH); + + const keyboardToggle = container.querySelector( + '[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)); + }); +}); diff --git a/ui/src/pages/InstanceGeneralSettings.tsx b/ui/src/pages/InstanceGeneralSettings.tsx index 3737e95c04..1580f279ea 100644 --- a/ui/src/pages/InstanceGeneralSettings.tsx +++ b/ui/src/pages/InstanceGeneralSettings.tsx @@ -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(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 (
@@ -98,9 +99,9 @@ export function InstanceGeneralSettings() {

- {actionError && ( + {visibleActionError && (
- {actionError} + {visibleActionError}
)} @@ -150,7 +151,7 @@ export function InstanceGeneralSettings() { updateGeneralMutation.mutate({ censorUsernameInLogs: !censorUsernameInLogs })} - disabled={updateGeneralMutation.isPending} + disabled={updateGeneralMutation.isPending || signOutMutation.isPending} aria-label="Toggle username log censoring" /> @@ -168,7 +169,7 @@ export function InstanceGeneralSettings() { updateGeneralMutation.mutate({ keyboardShortcuts: !keyboardShortcuts })} - disabled={updateGeneralMutation.isPending} + disabled={updateGeneralMutation.isPending || signOutMutation.isPending} aria-label="Toggle keyboard shortcuts" /> @@ -194,7 +195,7 @@ export function InstanceGeneralSettings() {