From b11c272e87eaf8aa156f03ce12ecb78cd6200d4f Mon Sep 17 00:00:00 2001 From: Anthony Stirling <77850077+Frooodle@users.noreply.github.com> Date: Fri, 12 Jun 2026 13:13:38 +0100 Subject: [PATCH] Feature/v2/guest action gating (#6643) --- .../public/locales/en-GB/translation.toml | 5 +- .../public/locales/en-US/translation.toml | 2 + frontend/editor/src/core/auth/UseSession.tsx | 9 + .../components/filesPage/FileManagerView.tsx | 37 ++- .../src/core/contexts/FilesPageContext.tsx | 5 +- .../src/core/contexts/FolderContext.test.tsx | 53 ++++ .../src/core/contexts/FolderContext.tsx | 19 +- .../src/core/services/fileSyncService.ts | 8 +- .../src/proprietary/auth/UseSession.tsx | 4 + .../src/proprietary/routes/Login.test.tsx | 3 + frontend/editor/src/saas/auth/UseSession.tsx | 4 + .../config/configSections/McpSection.tsx | 235 ++++++++++++------ 12 files changed, 291 insertions(+), 93 deletions(-) diff --git a/frontend/editor/public/locales/en-GB/translation.toml b/frontend/editor/public/locales/en-GB/translation.toml index 782be3cc9..5cf1de401 100644 --- a/frontend/editor/public/locales/en-GB/translation.toml +++ b/frontend/editor/public/locales/en-GB/translation.toml @@ -2693,9 +2693,9 @@ agentMenu = "Stirling agent options" clearChat = "Clear chat" [chat.input] +disclaimer = "AI can make mistakes. Be sure to verify the output before sharing." placeholder = "What do you want to do?" send = "Send message" -disclaimer = "AI can make mistakes. Be sure to verify the output before sharing." [chat.progress] analyzing = "Analysing your request..." @@ -3061,6 +3061,7 @@ warning = "⚠️ Warning: This action will generate new API keys and make your [config.mcp] description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf." +guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf." navLabel = "MCP Server" tip = "Every action your assistant runs is performed as your account and counts towards your usage, just like using the Stirling PDF API and Automation." title = "MCP Server" @@ -3779,6 +3780,7 @@ selectFile = "Select file {{name}}" shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it." shareManage = "Manage sharing" showDetails = "Show details" +signInRequired = "Sign in to use cloud storage." summary = "{{count}} items" tree = "Folders" upload = "Upload" @@ -5958,7 +5960,6 @@ stepOf = "Step {{step}} of {{total}}" toolChainDesc = "Configure the tools this policy runs on each document." typesSelected = "{{count}} types selected" - [printFile] title = "Print File" diff --git a/frontend/editor/public/locales/en-US/translation.toml b/frontend/editor/public/locales/en-US/translation.toml index 3a3b8b801..9bf3af718 100644 --- a/frontend/editor/public/locales/en-US/translation.toml +++ b/frontend/editor/public/locales/en-US/translation.toml @@ -3034,6 +3034,7 @@ warning = "⚠️ Warning: This action will generate new API keys and make your [config.mcp] description = "Model Context Protocol (MCP) lets AI assistants like Claude use your Stirling PDF tools directly. Connect a client once and your assistant can convert, edit, secure and process documents on your behalf." +guestInfo = "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf." navLabel = "MCP Server" tip = "Every action your assistant runs is performed as your account and counts toward your usage, just like using Stirling PDF directly." title = "MCP Server" @@ -3752,6 +3753,7 @@ selectFile = "Select file {{name}}" shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it." shareManage = "Manage sharing" showDetails = "Show details" +signInRequired = "Sign in to use cloud storage." summary = "{{count}} items" tree = "Folders" upload = "Upload" diff --git a/frontend/editor/src/core/auth/UseSession.tsx b/frontend/editor/src/core/auth/UseSession.tsx index 8f9a44f52..ee3314606 100644 --- a/frontend/editor/src/core/auth/UseSession.tsx +++ b/frontend/editor/src/core/auth/UseSession.tsx @@ -14,6 +14,14 @@ export interface AuthContextType { * should treat the resulting string as opaque display text. */ displayName: string | null; + /** + * Whether the current session is an anonymous / guest one. Each layer + * derives this from its own native user shape (Supabase `is_anonymous` in + * SaaS, the Spring anonymous flag in proprietary). Always `false` in core + * OSS, which has no auth context. Consumers use it to gate account-only + * actions (cloud folders, MCP) without reaching into a layer-specific user. + */ + isAnonymous: boolean; loading: boolean; error: Error | null; signOut: () => Promise; @@ -29,6 +37,7 @@ export function useAuth(): AuthContextType { session: null, user: null, displayName: null, + isAnonymous: false, loading: false, error: null, signOut: async () => {}, diff --git a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx index b13315cb4..319871006 100644 --- a/frontend/editor/src/core/components/filesPage/FileManagerView.tsx +++ b/frontend/editor/src/core/components/filesPage/FileManagerView.tsx @@ -35,6 +35,7 @@ import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight"; import RefreshIcon from "@mui/icons-material/Refresh"; import { stripBasePath } from "@app/constants/app"; +import { useAuth } from "@app/auth/UseSession"; import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { useFolders } from "@app/contexts/FolderContext"; import { useFileActions } from "@app/contexts/file/fileHooks"; @@ -107,17 +108,27 @@ export default function FileManagerView() { const isMobile = useIsMobile(); const isMobileUploadAvailable = Boolean(appConfig?.enableMobileScanner) && !isMobile; + // Guests (anonymous sessions) have no server-side storage, so every cloud + // action is account-only. Rather than let the click fire a guaranteed 401 + // (which surfaced as an error toast), we disable the control and explain why + // on hover - the same affordance the storage-disabled / wrong-tab gates use. + const { isAnonymous } = useAuth(); + const signInRequiredReason = isAnonymous + ? t("filesPage.signInRequired", "Sign in to use cloud storage.") + : null; // Server storage gate; mirrors ConfigController's storageEnabled // (enableLogin && storage.isEnabled). When off, Save-to-server stays // visible but disabled with an explanatory tooltip (discoverability beats // hiding - mirrors the New folder / Manage sharing gates in this view). const uploadEnabled = appConfig?.storageEnabled === true; - const saveToServerDisabledReason: string | null = uploadEnabled - ? null - : t( - "filesPage.saveToServerDisabledHint", - "Saving to the server isn't enabled on this server. Ask your admin to enable it.", - ); + const saveToServerDisabledReason: string | null = + signInRequiredReason ?? + (uploadEnabled + ? null + : t( + "filesPage.saveToServerDisabledHint", + "Saving to the server isn't enabled on this server. Ask your admin to enable it.", + )); const [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false); const { actions: navActions } = useNavigationActions(); const { requestNavigation } = useNavigationGuard(); @@ -805,6 +816,11 @@ export default function FileManagerView() { // null = New folder actionable; string = disabled tooltip reason. const newFolderDisabledReason: string | null = useMemo(() => { + // Guests can't use cloud folders at all - say so before any tab/storage + // hint, since switching tabs wouldn't help them. + if (signInRequiredReason) { + return signInRequiredReason; + } if (currentTab === "local") { return t( "filesPage.localFoldersUnavailable", @@ -828,7 +844,7 @@ export default function FileManagerView() { ); } return null; - }, [currentTab, folders.serverReachable, t]); + }, [signInRequiredReason, currentTab, folders.serverReachable, t]); return (
@@ -895,14 +911,17 @@ export default function FileManagerView() { />
([]); const [loading, setLoading] = useState(true); @@ -166,6 +168,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) { const merged = await reconcileServerFiles(localLeaf, { storageEnabled, shareLinksEnabled, + isAnonymous, }); // Drop the merged result if a newer refresh has already started - // otherwise its stale snapshot will clobber the newer one's state. @@ -181,7 +184,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) { // Only the latest refresh should clear the loading state. if (gen === refreshGenRef.current) setLoading(false); } - }, [setFoldersError, storageEnabled, shareLinksEnabled]); + }, [setFoldersError, storageEnabled, shareLinksEnabled, isAnonymous]); useEffect(() => { void refresh(); diff --git a/frontend/editor/src/core/contexts/FolderContext.test.tsx b/frontend/editor/src/core/contexts/FolderContext.test.tsx index 1734bf350..15d3b02ef 100644 --- a/frontend/editor/src/core/contexts/FolderContext.test.tsx +++ b/frontend/editor/src/core/contexts/FolderContext.test.tsx @@ -39,6 +39,31 @@ vi.mock("@app/services/folderSyncService", () => ({ }, })); +// FolderProvider only pulls from the server for a confirmed, non-anonymous +// user (guests have no cloud storage). Mock useAuth as a signed-in user so the +// pull runs; the guest-skip path is covered by its own test below. +const { mockAuth } = vi.hoisted(() => ({ + mockAuth: { + user: { id: "test-user", is_anonymous: false } as Record< + string, + unknown + > | null, + isAnonymous: false, + }, +})); +vi.mock("@app/auth/UseSession", () => ({ + useAuth: () => ({ + user: mockAuth.user, + isAnonymous: mockAuth.isAnonymous, + session: null, + displayName: null, + loading: false, + error: null, + signOut: vi.fn(), + refreshSession: vi.fn(), + }), +})); + // Stateful IDB mock - the revision-driven refresh re-reads getAllFolders // after every state change, so a stateless [] mock would clobber pull results. const { mockIdb } = vi.hoisted(() => ({ @@ -133,6 +158,9 @@ describe("FolderContext sync-banner gating", () => { mockList.mockReset(); mockUpdate.mockReset(); mockDelete.mockReset(); + // Default each test to a signed-in, non-anonymous user so the pull runs. + mockAuth.user = { id: "test-user", is_anonymous: false }; + mockAuth.isAnonymous = false; }); test("401 (unauthorized) does NOT surface a banner", async () => { @@ -184,6 +212,28 @@ describe("FolderContext sync-banner gating", () => { expect(screen.getByTestId("error").textContent).toBe(""); expect(screen.getByTestId("reachable").textContent).toBe("false"); }); + + test("guest (anonymous) session does NOT pull from the server", async () => { + // Guests have no cloud storage; pulling would 401 and (historically) + // surface a toast. The provider must make no folder request at all. + mockAuth.user = { id: "guest", is_anonymous: true }; + mockAuth.isAnonymous = true; + mockList.mockResolvedValue([]); + render( + + + + + , + ); + // Let mount effects run; the pull must never fire. + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + expect(mockList).not.toHaveBeenCalled(); + expect(screen.getByTestId("reachable").textContent).toBe("false"); + }); }); /** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */ @@ -194,6 +244,9 @@ describe("FolderContext stale-folder 404 cleanup", () => { mockDelete.mockReset(); // Reset the stateful IDB mock so each test starts with an empty cache. mockIdb.folders = []; + // Signed-in, non-anonymous user so pullFromServer runs. + mockAuth.user = { id: "test-user", is_anonymous: false }; + mockAuth.isAnonymous = false; }); function makeFolder(name: string, parentFolderId: FolderId | null = null) { diff --git a/frontend/editor/src/core/contexts/FolderContext.tsx b/frontend/editor/src/core/contexts/FolderContext.tsx index 81aba6a2b..b79245237 100644 --- a/frontend/editor/src/core/contexts/FolderContext.tsx +++ b/frontend/editor/src/core/contexts/FolderContext.tsx @@ -38,6 +38,7 @@ import { } from "@app/types/folder"; import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { useAppConfig } from "@app/contexts/AppConfigContext"; +import { useAuth } from "@app/auth/UseSession"; import { useLocation } from "react-router-dom"; import { isAuthRoute } from "@app/constants/routes"; @@ -369,10 +370,24 @@ export function FolderProvider({ children }: FolderProviderProps) { // /login has no session yet, so the pull would be a guaranteed 401. const location = useLocation(); const onAuthRoute = isAuthRoute(location.pathname); + // Guests (anonymous sessions) have no server-side storage, so a pull is a + // guaranteed 401 - skip it once we know the session is anonymous. This is + // what keeps the "must sign in" affordance toast-free: with no folder request + // fired, there's no error to surface. We gate on `isAnonymous` (which is + // false for a real signed-in user) rather than the auth `loading` flag, which + // can stay true for an authenticated session and would otherwise block the + // pull for legitimate users. + const { user, isAnonymous } = useAuth(); useEffect(() => { - if (!storageBackedByServer || onAuthRoute) return; + // Only pull once we have a confirmed, non-anonymous user. Skipping while + // `user` is still null avoids a stray 401 in the brief window before an + // anonymous session resolves (at which point `isAnonymous` flips true and + // keeps us out). `isAnonymous` alone isn't enough because it defaults false + // before the session loads; the auth `loading` flag is unreliable (it can + // stay true for a valid signed-in session), so we key off the user object. + if (!storageBackedByServer || onAuthRoute || !user || isAnonymous) return; void pullFromServer(); - }, [pullFromServer, storageBackedByServer, onAuthRoute]); + }, [pullFromServer, storageBackedByServer, onAuthRoute, user, isAnonymous]); const foldersById = useMemo(() => { const map = new Map(); diff --git a/frontend/editor/src/core/services/fileSyncService.ts b/frontend/editor/src/core/services/fileSyncService.ts index ffd182868..8d80563f8 100644 --- a/frontend/editor/src/core/services/fileSyncService.ts +++ b/frontend/editor/src/core/services/fileSyncService.ts @@ -63,6 +63,12 @@ interface AccessedShareLinkResponse { export interface ReconcileOptions { storageEnabled: boolean; shareLinksEnabled: boolean; + /** + * Guests (anonymous sessions) have no cloud library; pulling it just 401s and + * surfaces a "sign in to load cloud files" toast. Skip the server pull for + * them and fall back to locally-cached files only. + */ + isAnonymous?: boolean; } function normalizeServerFileName(fileName: string | undefined | null): string { @@ -108,7 +114,7 @@ export async function reconcileServerFiles( localStubs: StirlingFileStub[], opts: ReconcileOptions, ): Promise { - if (!opts.storageEnabled) { + if (!opts.storageEnabled || opts.isAnonymous) { return localStubs; } diff --git a/frontend/editor/src/proprietary/auth/UseSession.tsx b/frontend/editor/src/proprietary/auth/UseSession.tsx index 82ea8b63c..00532ff62 100644 --- a/frontend/editor/src/proprietary/auth/UseSession.tsx +++ b/frontend/editor/src/proprietary/auth/UseSession.tsx @@ -34,6 +34,8 @@ interface AuthContextType { * consumers can fall back to whatever makes sense. */ displayName: string | null; + /** Whether the current session is an anonymous (login-disabled) one. */ + isAnonymous: boolean; loading: boolean; error: AuthError | null; signOut: () => Promise; @@ -61,6 +63,7 @@ const AuthContext = createContext({ session: null, user: null, displayName: null, + isAnonymous: false, loading: true, error: null, signOut: async () => {}, @@ -292,6 +295,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { session, user, displayName: deriveDisplayName(user, t), + isAnonymous: user?.is_anonymous === true, loading, error, signOut, diff --git a/frontend/editor/src/proprietary/routes/Login.test.tsx b/frontend/editor/src/proprietary/routes/Login.test.tsx index 4846ce147..ea3c8cfbd 100644 --- a/frontend/editor/src/proprietary/routes/Login.test.tsx +++ b/frontend/editor/src/proprietary/routes/Login.test.tsx @@ -110,6 +110,7 @@ describe("Login", () => { session: null, user: null, displayName: null, + isAnonymous: false, loading: false, error: null, signOut: vi.fn(), @@ -157,6 +158,7 @@ describe("Login", () => { session: mockSession, user: mockSession.user, displayName: mockSession.user.username, + isAnonymous: false, loading: false, error: null, signOut: vi.fn(), @@ -181,6 +183,7 @@ describe("Login", () => { session: null, user: null, displayName: null, + isAnonymous: false, loading: true, error: null, signOut: vi.fn(), diff --git a/frontend/editor/src/saas/auth/UseSession.tsx b/frontend/editor/src/saas/auth/UseSession.tsx index 8ed451ec4..fd7ee93c1 100644 --- a/frontend/editor/src/saas/auth/UseSession.tsx +++ b/frontend/editor/src/saas/auth/UseSession.tsx @@ -75,6 +75,8 @@ interface AuthContextType { * consumers can fall back to whatever makes sense. */ displayName: string | null; + /** Whether the current session is an anonymous (Supabase `is_anonymous`) guest. */ + isAnonymous: boolean; loading: boolean; error: AuthError | null; creditBalance: number | null; @@ -99,6 +101,7 @@ const AuthContext = createContext({ session: null, user: null, displayName: null, + isAnonymous: false, loading: true, error: null, creditBalance: null, @@ -671,6 +674,7 @@ export function AuthProvider({ children }: { children: ReactNode }) { session, user, displayName: deriveDisplayName(user, t), + isAnonymous: Boolean(user?.is_anonymous), loading, error, creditBalance, diff --git a/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx b/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx index e2bfdd2fb..6a6ea06b6 100644 --- a/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx +++ b/frontend/editor/src/saas/components/shared/config/configSections/McpSection.tsx @@ -16,6 +16,8 @@ import { import LocalIcon from "@app/components/shared/LocalIcon"; import { useAppConfig } from "@app/contexts/AppConfigContext"; import { openAppSettings } from "@app/utils/appSettings"; +import { useAuth } from "@app/auth/UseSession"; +import { isUserAnonymous } from "@app/auth/supabase"; /** Strip a single trailing slash so we can safely append paths. */ function trimTrailingSlash(url: string): string { @@ -66,6 +68,18 @@ function CopyInline({ value, label }: { value: string; label: string }) { export default function McpSection() { const { t } = useTranslation(); const { config } = useAppConfig(); + const { user } = useAuth(); + // Guests can't authorise an MCP client - the OAuth flow mints an anonymous + // token with no email, which the server can't map to a Stirling account. So + // mirror the API-keys section: show a "create an account" card instead of a + // connection guide that would only dead-end at sign-in. + const isAnonymous = Boolean(user && isUserAnonymous(user)); + + const goToAccount = () => { + window.dispatchEvent( + new CustomEvent("appConfig:navigate", { detail: { key: "overview" } }), + ); + }; const baseUrl = useMemo(() => { const raw = @@ -80,9 +94,29 @@ export default function McpSection() { const clients = useMemo( () => [ { - value: "claude", + value: "claude-desktop", label: "Claude Desktop", file: "claude_desktop_config.json", + // Claude Desktop loads only stdio servers from this file, so the remote + // HTTP endpoint is bridged through the `mcp-remote` npm package (run via + // npx - needs Node.js installed). First launch opens a browser to sign in. + config: JSON.stringify( + { + mcpServers: { + "stirling-pdf": { + command: "npx", + args: ["-y", "mcp-remote", mcpUrl], + }, + }, + }, + null, + 2, + ), + }, + { + value: "claude-code", + label: "Claude Code", + file: ".mcp.json", config: JSON.stringify( { mcpServers: { "stirling-pdf": { type: "http", url: mcpUrl } } }, null, @@ -129,89 +163,134 @@ export default function McpSection() {
- {/* Endpoint */} - - - - - {t("config.mcp.endpoint.label", "Your MCP endpoint")} - - {mcpUrl} + {isAnonymous ? ( + + + + + {t( + "config.mcp.guestInfo", + "Guest users can't connect MCP clients. Create an account to use the MCP server and let your AI assistant run Stirling PDF tools on your behalf.", + )} + + + - - - + + ) : ( + <> + {/* Endpoint */} + + + + + {t("config.mcp.endpoint.label", "Your MCP endpoint")} + + {mcpUrl} + + + + - {/* Per-client setup */} - - - - {t("config.mcp.setup.title", "Connect your AI assistant")} - - - {t( - "config.mcp.setup.hint", - "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy.", - )} - - - - {clients.map((c) => ( - - {c.label} - - ))} - - {clients.map((c) => ( - - - - - {t("config.mcp.setup.addTo", "Add to")}{" "} - {c.file} - - - - {c.config} - - - ))} - - - + {/* Per-client setup */} + + + + {t("config.mcp.setup.title", "Connect your AI assistant")} + + + {t( + "config.mcp.setup.hint", + "Pick your client, paste the snippet into the file shown, then restart it. You'll sign in with your Stirling account on first use - no keys to copy.", + )} + + + + {clients.map((c) => ( + + {c.label} + + ))} + + {clients.map((c) => ( + + + + + {t("config.mcp.setup.addTo", "Add to")}{" "} + {c.file} + + + + {c.config} + + + ))} + + + - {/* Tip / cross-link */} - } - > - - - {t( - "config.mcp.tip", - "Every action your assistant runs is performed as your account and counts towards your usage, just like using the Stirling PDF API and Automation.", - )} - - - - + + + {t( + "config.mcp.tip", + "Every action your assistant runs is performed as your account and counts towards your usage, just like using the Stirling PDF API and Automation.", + )} + + + + + + )}
);