Feature/v2/guest action gating (#6643)

This commit is contained in:
Anthony Stirling
2026-06-12 13:13:38 +01:00
committed by GitHub
parent f5e697347b
commit b11c272e87
12 changed files with 291 additions and 93 deletions
@@ -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"
@@ -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"
@@ -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<void>;
@@ -29,6 +37,7 @@ export function useAuth(): AuthContextType {
session: null,
user: null,
displayName: null,
isAnonymous: false,
loading: false,
error: null,
signOut: async () => {},
@@ -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
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 (
<div className="files-page" ref={dropZoneRef}>
@@ -895,14 +911,17 @@ export default function FileManagerView() {
/>
<div className="files-page-header-actions">
<Tooltip
label={t("filesPage.refresh", "Refresh from server")}
label={
signInRequiredReason ??
t("filesPage.refresh", "Refresh from server")
}
withinPortal
>
<ActionIcon
variant="default"
size="md"
loading={refreshing}
disabled={refreshing}
disabled={refreshing || Boolean(signInRequiredReason)}
aria-busy={refreshing}
onClick={handleRefresh}
aria-label={t("filesPage.refresh", "Refresh from server")}
@@ -25,6 +25,7 @@ import {
import { useFileActions } from "@app/contexts/file/fileHooks";
import { useFolders } from "@app/contexts/FolderContext";
import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
/** View-toggle modes; tuple keeps the union and iterator in sync. */
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const;
@@ -140,6 +141,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const folders = useFolders();
const { actions: fileActions } = useFileActions();
const { config: appConfig } = useAppConfig();
const { isAnonymous } = useAuth();
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
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();
@@ -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("<null>");
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(
<MemoryRouter>
<FolderProvider>
<Probe />
</FolderProvider>
</MemoryRouter>,
);
// 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) {
@@ -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<FolderId, FolderRecord>();
@@ -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<StirlingFileStub[]> {
if (!opts.storageEnabled) {
if (!opts.storageEnabled || opts.isAnonymous) {
return localStubs;
}
@@ -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<void>;
@@ -61,6 +63,7 @@ const AuthContext = createContext<AuthContextType>({
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,
@@ -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(),
@@ -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<AuthContextType>({
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,
@@ -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,6 +163,33 @@ export default function McpSection() {
</Text>
</div>
{isAnonymous ? (
<Paper withBorder p="md" radius="md">
<Stack gap={10}>
<Group
justify="space-between"
wrap="nowrap"
align="center"
style={{ gap: "1rem" }}
>
<Text size="sm" c="dimmed" style={{ flex: 1 }}>
{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.",
)}
</Text>
<Button
size="sm"
onClick={goToAccount}
style={{ flexShrink: 0 }}
>
{t("config.apiKeys.goToAccount", "Go to Account")}
</Button>
</Group>
</Stack>
</Paper>
) : (
<>
{/* Endpoint */}
<Paper withBorder p="sm" radius="md">
<Group gap="xs" wrap="nowrap" align="center">
@@ -157,7 +218,12 @@ export default function McpSection() {
"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.",
)}
</Text>
<Tabs defaultValue="claude" variant="pills" radius="md" mt={4}>
<Tabs
defaultValue="claude-desktop"
variant="pills"
radius="md"
mt={4}
>
<Tabs.List>
{clients.map((c) => (
<Tabs.Tab key={c.value} value={c.value}>
@@ -168,7 +234,11 @@ export default function McpSection() {
{clients.map((c) => (
<Tabs.Panel key={c.value} value={c.value} pt="sm">
<Stack gap="xs">
<Group justify="space-between" align="center" wrap="nowrap">
<Group
justify="space-between"
align="center"
wrap="nowrap"
>
<Text size="xs" c="dimmed">
{t("config.mcp.setup.addTo", "Add to")}{" "}
<Code>{c.file}</Code>
@@ -190,9 +260,16 @@ export default function McpSection() {
<Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
icon={
<LocalIcon icon="info-rounded" width="1rem" height="1rem" />
}
>
<Group
justify="space-between"
align="center"
wrap="nowrap"
gap="sm"
>
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Text size="sm">
{t(
"config.mcp.tip",
@@ -212,6 +289,8 @@ export default function McpSection() {
</Button>
</Group>
</Alert>
</>
)}
</Stack>
</div>
);