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" clearChat = "Clear chat"
[chat.input] [chat.input]
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
placeholder = "What do you want to do?" placeholder = "What do you want to do?"
send = "Send message" send = "Send message"
disclaimer = "AI can make mistakes. Be sure to verify the output before sharing."
[chat.progress] [chat.progress]
analyzing = "Analysing your request..." analyzing = "Analysing your request..."
@@ -3061,6 +3061,7 @@ warning = "⚠️ Warning: This action will generate new API keys and make your
[config.mcp] [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." 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" 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." 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" 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." shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
shareManage = "Manage sharing" shareManage = "Manage sharing"
showDetails = "Show details" showDetails = "Show details"
signInRequired = "Sign in to use cloud storage."
summary = "{{count}} items" summary = "{{count}} items"
tree = "Folders" tree = "Folders"
upload = "Upload" upload = "Upload"
@@ -5958,7 +5960,6 @@ stepOf = "Step {{step}} of {{total}}"
toolChainDesc = "Configure the tools this policy runs on each document." toolChainDesc = "Configure the tools this policy runs on each document."
typesSelected = "{{count}} types selected" typesSelected = "{{count}} types selected"
[printFile] [printFile]
title = "Print File" title = "Print File"
@@ -3034,6 +3034,7 @@ warning = "⚠️ Warning: This action will generate new API keys and make your
[config.mcp] [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." 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" 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." 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" 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." shareDisabledHint = "File sharing isn't enabled on this server. Ask your admin to enable it."
shareManage = "Manage sharing" shareManage = "Manage sharing"
showDetails = "Show details" showDetails = "Show details"
signInRequired = "Sign in to use cloud storage."
summary = "{{count}} items" summary = "{{count}} items"
tree = "Folders" tree = "Folders"
upload = "Upload" upload = "Upload"
@@ -14,6 +14,14 @@ export interface AuthContextType {
* should treat the resulting string as opaque display text. * should treat the resulting string as opaque display text.
*/ */
displayName: string | null; 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; loading: boolean;
error: Error | null; error: Error | null;
signOut: () => Promise<void>; signOut: () => Promise<void>;
@@ -29,6 +37,7 @@ export function useAuth(): AuthContextType {
session: null, session: null,
user: null, user: null,
displayName: null, displayName: null,
isAnonymous: false,
loading: false, loading: false,
error: null, error: null,
signOut: async () => {}, signOut: async () => {},
@@ -35,6 +35,7 @@ import KeyboardArrowRightIcon from "@mui/icons-material/KeyboardArrowRight";
import RefreshIcon from "@mui/icons-material/Refresh"; import RefreshIcon from "@mui/icons-material/Refresh";
import { stripBasePath } from "@app/constants/app"; import { stripBasePath } from "@app/constants/app";
import { useAuth } from "@app/auth/UseSession";
import { useSharingEnabled } from "@app/hooks/useSharingEnabled"; import { useSharingEnabled } from "@app/hooks/useSharingEnabled";
import { useFolders } from "@app/contexts/FolderContext"; import { useFolders } from "@app/contexts/FolderContext";
import { useFileActions } from "@app/contexts/file/fileHooks"; import { useFileActions } from "@app/contexts/file/fileHooks";
@@ -107,17 +108,27 @@ export default function FileManagerView() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const isMobileUploadAvailable = const isMobileUploadAvailable =
Boolean(appConfig?.enableMobileScanner) && !isMobile; 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 // Server storage gate; mirrors ConfigController's storageEnabled
// (enableLogin && storage.isEnabled). When off, Save-to-server stays // (enableLogin && storage.isEnabled). When off, Save-to-server stays
// visible but disabled with an explanatory tooltip (discoverability beats // visible but disabled with an explanatory tooltip (discoverability beats
// hiding - mirrors the New folder / Manage sharing gates in this view). // hiding - mirrors the New folder / Manage sharing gates in this view).
const uploadEnabled = appConfig?.storageEnabled === true; const uploadEnabled = appConfig?.storageEnabled === true;
const saveToServerDisabledReason: string | null = uploadEnabled const saveToServerDisabledReason: string | null =
? null signInRequiredReason ??
: t( (uploadEnabled
"filesPage.saveToServerDisabledHint", ? null
"Saving to the server isn't enabled on this server. Ask your admin to enable it.", : 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 [mobileUploadModalOpen, setMobileUploadModalOpen] = useState(false);
const { actions: navActions } = useNavigationActions(); const { actions: navActions } = useNavigationActions();
const { requestNavigation } = useNavigationGuard(); const { requestNavigation } = useNavigationGuard();
@@ -805,6 +816,11 @@ export default function FileManagerView() {
// null = New folder actionable; string = disabled tooltip reason. // null = New folder actionable; string = disabled tooltip reason.
const newFolderDisabledReason: string | null = useMemo(() => { 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") { if (currentTab === "local") {
return t( return t(
"filesPage.localFoldersUnavailable", "filesPage.localFoldersUnavailable",
@@ -828,7 +844,7 @@ export default function FileManagerView() {
); );
} }
return null; return null;
}, [currentTab, folders.serverReachable, t]); }, [signInRequiredReason, currentTab, folders.serverReachable, t]);
return ( return (
<div className="files-page" ref={dropZoneRef}> <div className="files-page" ref={dropZoneRef}>
@@ -895,14 +911,17 @@ export default function FileManagerView() {
/> />
<div className="files-page-header-actions"> <div className="files-page-header-actions">
<Tooltip <Tooltip
label={t("filesPage.refresh", "Refresh from server")} label={
signInRequiredReason ??
t("filesPage.refresh", "Refresh from server")
}
withinPortal withinPortal
> >
<ActionIcon <ActionIcon
variant="default" variant="default"
size="md" size="md"
loading={refreshing} loading={refreshing}
disabled={refreshing} disabled={refreshing || Boolean(signInRequiredReason)}
aria-busy={refreshing} aria-busy={refreshing}
onClick={handleRefresh} onClick={handleRefresh}
aria-label={t("filesPage.refresh", "Refresh from server")} aria-label={t("filesPage.refresh", "Refresh from server")}
@@ -25,6 +25,7 @@ import {
import { useFileActions } from "@app/contexts/file/fileHooks"; import { useFileActions } from "@app/contexts/file/fileHooks";
import { useFolders } from "@app/contexts/FolderContext"; import { useFolders } from "@app/contexts/FolderContext";
import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
/** View-toggle modes; tuple keeps the union and iterator in sync. */ /** View-toggle modes; tuple keeps the union and iterator in sync. */
export const FILES_PAGE_VIEW_MODES = ["grid", "list"] as const; 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 folders = useFolders();
const { actions: fileActions } = useFileActions(); const { actions: fileActions } = useFileActions();
const { config: appConfig } = useAppConfig(); const { config: appConfig } = useAppConfig();
const { isAnonymous } = useAuth();
const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]); const [allFiles, setAllFiles] = useState<StirlingFileStub[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -166,6 +168,7 @@ export function FilesPageProvider({ children }: { children: React.ReactNode }) {
const merged = await reconcileServerFiles(localLeaf, { const merged = await reconcileServerFiles(localLeaf, {
storageEnabled, storageEnabled,
shareLinksEnabled, shareLinksEnabled,
isAnonymous,
}); });
// Drop the merged result if a newer refresh has already started - // Drop the merged result if a newer refresh has already started -
// otherwise its stale snapshot will clobber the newer one's state. // 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. // Only the latest refresh should clear the loading state.
if (gen === refreshGenRef.current) setLoading(false); if (gen === refreshGenRef.current) setLoading(false);
} }
}, [setFoldersError, storageEnabled, shareLinksEnabled]); }, [setFoldersError, storageEnabled, shareLinksEnabled, isAnonymous]);
useEffect(() => { useEffect(() => {
void refresh(); 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 // Stateful IDB mock - the revision-driven refresh re-reads getAllFolders
// after every state change, so a stateless [] mock would clobber pull results. // after every state change, so a stateless [] mock would clobber pull results.
const { mockIdb } = vi.hoisted(() => ({ const { mockIdb } = vi.hoisted(() => ({
@@ -133,6 +158,9 @@ describe("FolderContext sync-banner gating", () => {
mockList.mockReset(); mockList.mockReset();
mockUpdate.mockReset(); mockUpdate.mockReset();
mockDelete.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 () => { 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("error").textContent).toBe("<null>");
expect(screen.getByTestId("reachable").textContent).toBe("false"); 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). */ /** Per-folder mutation 404 = silent cleanup (drop subtree, no banner, pull). */
@@ -194,6 +244,9 @@ describe("FolderContext stale-folder 404 cleanup", () => {
mockDelete.mockReset(); mockDelete.mockReset();
// Reset the stateful IDB mock so each test starts with an empty cache. // Reset the stateful IDB mock so each test starts with an empty cache.
mockIdb.folders = []; 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) { function makeFolder(name: string, parentFolderId: FolderId | null = null) {
@@ -38,6 +38,7 @@ import {
} from "@app/types/folder"; } from "@app/types/folder";
import { useIndexedDB } from "@app/contexts/IndexedDBContext"; import { useIndexedDB } from "@app/contexts/IndexedDBContext";
import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAppConfig } from "@app/contexts/AppConfigContext";
import { useAuth } from "@app/auth/UseSession";
import { useLocation } from "react-router-dom"; import { useLocation } from "react-router-dom";
import { isAuthRoute } from "@app/constants/routes"; 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. // /login has no session yet, so the pull would be a guaranteed 401.
const location = useLocation(); const location = useLocation();
const onAuthRoute = isAuthRoute(location.pathname); 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(() => { 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(); void pullFromServer();
}, [pullFromServer, storageBackedByServer, onAuthRoute]); }, [pullFromServer, storageBackedByServer, onAuthRoute, user, isAnonymous]);
const foldersById = useMemo(() => { const foldersById = useMemo(() => {
const map = new Map<FolderId, FolderRecord>(); const map = new Map<FolderId, FolderRecord>();
@@ -63,6 +63,12 @@ interface AccessedShareLinkResponse {
export interface ReconcileOptions { export interface ReconcileOptions {
storageEnabled: boolean; storageEnabled: boolean;
shareLinksEnabled: 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 { function normalizeServerFileName(fileName: string | undefined | null): string {
@@ -108,7 +114,7 @@ export async function reconcileServerFiles(
localStubs: StirlingFileStub[], localStubs: StirlingFileStub[],
opts: ReconcileOptions, opts: ReconcileOptions,
): Promise<StirlingFileStub[]> { ): Promise<StirlingFileStub[]> {
if (!opts.storageEnabled) { if (!opts.storageEnabled || opts.isAnonymous) {
return localStubs; return localStubs;
} }
@@ -34,6 +34,8 @@ interface AuthContextType {
* consumers can fall back to whatever makes sense. * consumers can fall back to whatever makes sense.
*/ */
displayName: string | null; displayName: string | null;
/** Whether the current session is an anonymous (login-disabled) one. */
isAnonymous: boolean;
loading: boolean; loading: boolean;
error: AuthError | null; error: AuthError | null;
signOut: () => Promise<void>; signOut: () => Promise<void>;
@@ -61,6 +63,7 @@ const AuthContext = createContext<AuthContextType>({
session: null, session: null,
user: null, user: null,
displayName: null, displayName: null,
isAnonymous: false,
loading: true, loading: true,
error: null, error: null,
signOut: async () => {}, signOut: async () => {},
@@ -292,6 +295,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
session, session,
user, user,
displayName: deriveDisplayName(user, t), displayName: deriveDisplayName(user, t),
isAnonymous: user?.is_anonymous === true,
loading, loading,
error, error,
signOut, signOut,
@@ -110,6 +110,7 @@ describe("Login", () => {
session: null, session: null,
user: null, user: null,
displayName: null, displayName: null,
isAnonymous: false,
loading: false, loading: false,
error: null, error: null,
signOut: vi.fn(), signOut: vi.fn(),
@@ -157,6 +158,7 @@ describe("Login", () => {
session: mockSession, session: mockSession,
user: mockSession.user, user: mockSession.user,
displayName: mockSession.user.username, displayName: mockSession.user.username,
isAnonymous: false,
loading: false, loading: false,
error: null, error: null,
signOut: vi.fn(), signOut: vi.fn(),
@@ -181,6 +183,7 @@ describe("Login", () => {
session: null, session: null,
user: null, user: null,
displayName: null, displayName: null,
isAnonymous: false,
loading: true, loading: true,
error: null, error: null,
signOut: vi.fn(), signOut: vi.fn(),
@@ -75,6 +75,8 @@ interface AuthContextType {
* consumers can fall back to whatever makes sense. * consumers can fall back to whatever makes sense.
*/ */
displayName: string | null; displayName: string | null;
/** Whether the current session is an anonymous (Supabase `is_anonymous`) guest. */
isAnonymous: boolean;
loading: boolean; loading: boolean;
error: AuthError | null; error: AuthError | null;
creditBalance: number | null; creditBalance: number | null;
@@ -99,6 +101,7 @@ const AuthContext = createContext<AuthContextType>({
session: null, session: null,
user: null, user: null,
displayName: null, displayName: null,
isAnonymous: false,
loading: true, loading: true,
error: null, error: null,
creditBalance: null, creditBalance: null,
@@ -671,6 +674,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
session, session,
user, user,
displayName: deriveDisplayName(user, t), displayName: deriveDisplayName(user, t),
isAnonymous: Boolean(user?.is_anonymous),
loading, loading,
error, error,
creditBalance, creditBalance,
@@ -16,6 +16,8 @@ import {
import LocalIcon from "@app/components/shared/LocalIcon"; import LocalIcon from "@app/components/shared/LocalIcon";
import { useAppConfig } from "@app/contexts/AppConfigContext"; import { useAppConfig } from "@app/contexts/AppConfigContext";
import { openAppSettings } from "@app/utils/appSettings"; 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. */ /** Strip a single trailing slash so we can safely append paths. */
function trimTrailingSlash(url: string): string { function trimTrailingSlash(url: string): string {
@@ -66,6 +68,18 @@ function CopyInline({ value, label }: { value: string; label: string }) {
export default function McpSection() { export default function McpSection() {
const { t } = useTranslation(); const { t } = useTranslation();
const { config } = useAppConfig(); 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 baseUrl = useMemo(() => {
const raw = const raw =
@@ -80,9 +94,29 @@ export default function McpSection() {
const clients = useMemo( const clients = useMemo(
() => [ () => [
{ {
value: "claude", value: "claude-desktop",
label: "Claude Desktop", label: "Claude Desktop",
file: "claude_desktop_config.json", 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( config: JSON.stringify(
{ mcpServers: { "stirling-pdf": { type: "http", url: mcpUrl } } }, { mcpServers: { "stirling-pdf": { type: "http", url: mcpUrl } } },
null, null,
@@ -129,89 +163,134 @@ export default function McpSection() {
</Text> </Text>
</div> </div>
{/* Endpoint */} {isAnonymous ? (
<Paper withBorder p="sm" radius="md"> <Paper withBorder p="md" radius="md">
<Group gap="xs" wrap="nowrap" align="center"> <Stack gap={10}>
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}> <Group
<Text fw={500} size="sm"> justify="space-between"
{t("config.mcp.endpoint.label", "Your MCP endpoint")} wrap="nowrap"
</Text> align="center"
<Code style={{ overflowX: "auto" }}>{mcpUrl}</Code> 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> </Stack>
<CopyInline </Paper>
value={mcpUrl} ) : (
label={t("config.mcp.copy.endpointLabel", "Endpoint URL")} <>
/> {/* Endpoint */}
</Group> <Paper withBorder p="sm" radius="md">
</Paper> <Group gap="xs" wrap="nowrap" align="center">
<Stack gap={2} style={{ flex: 1, minWidth: 0 }}>
<Text fw={500} size="sm">
{t("config.mcp.endpoint.label", "Your MCP endpoint")}
</Text>
<Code style={{ overflowX: "auto" }}>{mcpUrl}</Code>
</Stack>
<CopyInline
value={mcpUrl}
label={t("config.mcp.copy.endpointLabel", "Endpoint URL")}
/>
</Group>
</Paper>
{/* Per-client setup */} {/* Per-client setup */}
<Paper withBorder p="sm" radius="md"> <Paper withBorder p="sm" radius="md">
<Stack gap="xs"> <Stack gap="xs">
<Text fw={500} size="sm"> <Text fw={500} size="sm">
{t("config.mcp.setup.title", "Connect your AI assistant")} {t("config.mcp.setup.title", "Connect your AI assistant")}
</Text> </Text>
<Text size="xs" c="dimmed"> <Text size="xs" c="dimmed">
{t( {t(
"config.mcp.setup.hint", "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.", "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> </Text>
<Tabs defaultValue="claude" variant="pills" radius="md" mt={4}> <Tabs
<Tabs.List> defaultValue="claude-desktop"
{clients.map((c) => ( variant="pills"
<Tabs.Tab key={c.value} value={c.value}> radius="md"
{c.label} mt={4}
</Tabs.Tab> >
))} <Tabs.List>
</Tabs.List> {clients.map((c) => (
{clients.map((c) => ( <Tabs.Tab key={c.value} value={c.value}>
<Tabs.Panel key={c.value} value={c.value} pt="sm"> {c.label}
<Stack gap="xs"> </Tabs.Tab>
<Group justify="space-between" align="center" wrap="nowrap"> ))}
<Text size="xs" c="dimmed"> </Tabs.List>
{t("config.mcp.setup.addTo", "Add to")}{" "} {clients.map((c) => (
<Code>{c.file}</Code> <Tabs.Panel key={c.value} value={c.value} pt="sm">
</Text> <Stack gap="xs">
<CopyInline <Group
value={c.config} justify="space-between"
label={t("config.mcp.copy.configLabel", "Config")} align="center"
/> wrap="nowrap"
</Group> >
<Code block>{c.config}</Code> <Text size="xs" c="dimmed">
</Stack> {t("config.mcp.setup.addTo", "Add to")}{" "}
</Tabs.Panel> <Code>{c.file}</Code>
))} </Text>
</Tabs> <CopyInline
</Stack> value={c.config}
</Paper> label={t("config.mcp.copy.configLabel", "Config")}
/>
</Group>
<Code block>{c.config}</Code>
</Stack>
</Tabs.Panel>
))}
</Tabs>
</Stack>
</Paper>
{/* Tip / cross-link */} {/* Tip / cross-link */}
<Alert <Alert
variant="light"
color="blue"
icon={<LocalIcon icon="info-rounded" width="1rem" height="1rem" />}
>
<Group justify="space-between" align="center" wrap="nowrap" gap="sm">
<Text size="sm">
{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.",
)}
</Text>
<Button
size="xs"
variant="light" variant="light"
style={{ flexShrink: 0 }} color="blue"
leftSection={ icon={
<LocalIcon icon="key-rounded" width={14} height={14} /> <LocalIcon icon="info-rounded" width="1rem" height="1rem" />
} }
onClick={() => openAppSettings("api-keys")}
> >
{t("config.mcp.viewApiKeys", "View API keys")} <Group
</Button> justify="space-between"
</Group> align="center"
</Alert> wrap="nowrap"
gap="sm"
>
<Text size="sm">
{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.",
)}
</Text>
<Button
size="xs"
variant="light"
style={{ flexShrink: 0 }}
leftSection={
<LocalIcon icon="key-rounded" width={14} height={14} />
}
onClick={() => openAppSettings("api-keys")}
>
{t("config.mcp.viewApiKeys", "View API keys")}
</Button>
</Group>
</Alert>
</>
)}
</Stack> </Stack>
</div> </div>
); );