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"
|
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>
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user