mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Feature/v2/guest action gating (#6643)
This commit is contained in:
@@ -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
|
||||
? 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 (
|
||||
<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,89 +163,134 @@ export default function McpSection() {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Endpoint */}
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<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>
|
||||
{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>
|
||||
<CopyInline
|
||||
value={mcpUrl}
|
||||
label={t("config.mcp.copy.endpointLabel", "Endpoint URL")}
|
||||
/>
|
||||
</Group>
|
||||
</Paper>
|
||||
</Paper>
|
||||
) : (
|
||||
<>
|
||||
{/* Endpoint */}
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<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 */}
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Text fw={500} size="sm">
|
||||
{t("config.mcp.setup.title", "Connect your AI assistant")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{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.",
|
||||
)}
|
||||
</Text>
|
||||
<Tabs defaultValue="claude" variant="pills" radius="md" mt={4}>
|
||||
<Tabs.List>
|
||||
{clients.map((c) => (
|
||||
<Tabs.Tab key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{clients.map((c) => (
|
||||
<Tabs.Panel key={c.value} value={c.value} pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between" align="center" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("config.mcp.setup.addTo", "Add to")}{" "}
|
||||
<Code>{c.file}</Code>
|
||||
</Text>
|
||||
<CopyInline
|
||||
value={c.config}
|
||||
label={t("config.mcp.copy.configLabel", "Config")}
|
||||
/>
|
||||
</Group>
|
||||
<Code block>{c.config}</Code>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Paper>
|
||||
{/* Per-client setup */}
|
||||
<Paper withBorder p="sm" radius="md">
|
||||
<Stack gap="xs">
|
||||
<Text fw={500} size="sm">
|
||||
{t("config.mcp.setup.title", "Connect your AI assistant")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{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.",
|
||||
)}
|
||||
</Text>
|
||||
<Tabs
|
||||
defaultValue="claude-desktop"
|
||||
variant="pills"
|
||||
radius="md"
|
||||
mt={4}
|
||||
>
|
||||
<Tabs.List>
|
||||
{clients.map((c) => (
|
||||
<Tabs.Tab key={c.value} value={c.value}>
|
||||
{c.label}
|
||||
</Tabs.Tab>
|
||||
))}
|
||||
</Tabs.List>
|
||||
{clients.map((c) => (
|
||||
<Tabs.Panel key={c.value} value={c.value} pt="sm">
|
||||
<Stack gap="xs">
|
||||
<Group
|
||||
justify="space-between"
|
||||
align="center"
|
||||
wrap="nowrap"
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{t("config.mcp.setup.addTo", "Add to")}{" "}
|
||||
<Code>{c.file}</Code>
|
||||
</Text>
|
||||
<CopyInline
|
||||
value={c.config}
|
||||
label={t("config.mcp.copy.configLabel", "Config")}
|
||||
/>
|
||||
</Group>
|
||||
<Code block>{c.config}</Code>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
))}
|
||||
</Tabs>
|
||||
</Stack>
|
||||
</Paper>
|
||||
|
||||
{/* Tip / cross-link */}
|
||||
<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"
|
||||
{/* Tip / cross-link */}
|
||||
<Alert
|
||||
variant="light"
|
||||
style={{ flexShrink: 0 }}
|
||||
leftSection={
|
||||
<LocalIcon icon="key-rounded" width={14} height={14} />
|
||||
color="blue"
|
||||
icon={
|
||||
<LocalIcon icon="info-rounded" width="1rem" height="1rem" />
|
||||
}
|
||||
onClick={() => openAppSettings("api-keys")}
|
||||
>
|
||||
{t("config.mcp.viewApiKeys", "View API keys")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Alert>
|
||||
<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"
|
||||
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>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user