diff --git a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_desktop.md b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_desktop.md index 4e5465aaf..b0f2dacb2 100644 --- a/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_desktop.md +++ b/surfsense_backend/app/agents/multi_agent_chat/subagents/builtins/knowledge_base/system_prompt_desktop.md @@ -33,7 +33,7 @@ Map outcomes to your `status`: - Any other `"Error: …"` → `status=error` and relay the tool's message verbatim as `next_step`. - HITL rejection → `status=blocked` with `next_step="User declined this filesystem action. Do not retry."`. -You construct the structured `evidence` fields from your own knowledge of what you called and what you observed — the tools do not return them. `chunk_ids` apply only to `` hits; for local-file operations leave them `null`. Never report values you did not actually see. +You construct the structured `evidence` fields from your own knowledge of what you called and what you observed — the tools do not return them. Never report values you did not actually see. (`chunk_ids` is always `null` in desktop mode — see "Chunk citations in your prose" below.) ## Chunk citations in your prose diff --git a/surfsense_desktop/.env.example b/surfsense_desktop/.env.example index e127b99e0..2d9de7561 100644 --- a/surfsense_desktop/.env.example +++ b/surfsense_desktop/.env.example @@ -5,6 +5,11 @@ # inside the desktop app. Set to your production frontend domain. HOSTED_FRONTEND_URL=https://surfsense.net +# Runtime override for the above (read at app start, no rebuild required). +# Useful for self-hosters whose backend NEXT_FRONTEND_URL differs from the +# value baked into the official desktop builds. Leave empty to use HOSTED_FRONTEND_URL. +# SURFSENSE_HOSTED_FRONTEND_URL_OVERRIDE= + # PostHog analytics (leave empty to disable) POSTHOG_KEY= POSTHOG_HOST=https://assets.surfsense.com diff --git a/surfsense_desktop/assets/icons/1024x1024.png b/surfsense_desktop/assets/icons/1024x1024.png new file mode 100644 index 000000000..853201c5e Binary files /dev/null and b/surfsense_desktop/assets/icons/1024x1024.png differ diff --git a/surfsense_desktop/assets/icons/128x128.png b/surfsense_desktop/assets/icons/128x128.png new file mode 100644 index 000000000..97286c8b6 Binary files /dev/null and b/surfsense_desktop/assets/icons/128x128.png differ diff --git a/surfsense_desktop/assets/icons/16x16.png b/surfsense_desktop/assets/icons/16x16.png new file mode 100644 index 000000000..860f9fef1 Binary files /dev/null and b/surfsense_desktop/assets/icons/16x16.png differ diff --git a/surfsense_desktop/assets/icons/256x256.png b/surfsense_desktop/assets/icons/256x256.png new file mode 100644 index 000000000..edb7aa512 Binary files /dev/null and b/surfsense_desktop/assets/icons/256x256.png differ diff --git a/surfsense_desktop/assets/icons/32x32.png b/surfsense_desktop/assets/icons/32x32.png new file mode 100644 index 000000000..2c1ef1222 Binary files /dev/null and b/surfsense_desktop/assets/icons/32x32.png differ diff --git a/surfsense_desktop/assets/icons/48x48.png b/surfsense_desktop/assets/icons/48x48.png new file mode 100644 index 000000000..2d765024d Binary files /dev/null and b/surfsense_desktop/assets/icons/48x48.png differ diff --git a/surfsense_desktop/assets/icons/512x512.png b/surfsense_desktop/assets/icons/512x512.png new file mode 100644 index 000000000..3fc480dd7 Binary files /dev/null and b/surfsense_desktop/assets/icons/512x512.png differ diff --git a/surfsense_desktop/assets/icons/64x64.png b/surfsense_desktop/assets/icons/64x64.png new file mode 100644 index 000000000..a218a4ee2 Binary files /dev/null and b/surfsense_desktop/assets/icons/64x64.png differ diff --git a/surfsense_desktop/electron-builder.yml b/surfsense_desktop/electron-builder.yml index e4e7670ec..0a7c48203 100644 --- a/surfsense_desktop/electron-builder.yml +++ b/surfsense_desktop/electron-builder.yml @@ -55,6 +55,11 @@ mac: NSAccessibilityUsageDescription: "SurfSense uses accessibility features to bring the app to the foreground and interact with the active application when you use desktop assists." NSScreenCaptureUsageDescription: "SurfSense uses screen capture so you can attach a selected region to chat (Screenshot Assist) or capture the full screen from the composer." NSAppleEventsUsageDescription: "SurfSense uses Apple Events to interact with the active application." + # `surfsense://` scheme — install-time registration for LaunchServices. + CFBundleURLTypes: + - CFBundleURLName: com.surfsense.desktop + CFBundleURLSchemes: + - surfsense target: - target: dmg arch: [x64, arm64] @@ -72,7 +77,7 @@ nsis: createDesktopShortcut: true createStartMenuShortcut: true linux: - icon: assets/icon.png + icon: assets/icons/ category: Utility artifactName: "${productName}-${version}-${arch}.${ext}" mimeTypes: diff --git a/surfsense_desktop/src/modules/deep-links.ts b/surfsense_desktop/src/modules/deep-links.ts index 635e242fe..d4c0da467 100644 --- a/surfsense_desktop/src/modules/deep-links.ts +++ b/surfsense_desktop/src/modules/deep-links.ts @@ -60,6 +60,11 @@ export function setupDeepLinks(): boolean { app.setAsDefaultProtocolClient(PROTOCOL); } + // Cold-start on Windows/Linux: protocol URL arrives via argv of the + // first instance, not via `second-instance` or `open-url`. + const cold = process.argv.find((arg) => arg.startsWith(`${PROTOCOL}://`)); + if (cold) handleDeepLink(cold); + return true; } diff --git a/surfsense_desktop/src/modules/server.ts b/surfsense_desktop/src/modules/server.ts index 55753d8fb..fc2fa05c3 100644 --- a/surfsense_desktop/src/modules/server.ts +++ b/surfsense_desktop/src/modules/server.ts @@ -49,6 +49,7 @@ export async function startNextServer(): Promise { env: { ...process.env, PORT: String(serverPort), + // Loopback bind: avoids 0.0.0.0 leaking into request.url and redirect origins. HOSTNAME: SERVER_HOST, NODE_ENV: 'production', }, diff --git a/surfsense_desktop/src/modules/window.ts b/surfsense_desktop/src/modules/window.ts index 153fa0879..e80bf7879 100644 --- a/surfsense_desktop/src/modules/window.ts +++ b/surfsense_desktop/src/modules/window.ts @@ -2,14 +2,31 @@ import { app, BrowserWindow, shell, session } from 'electron'; import path from 'path'; import { trackEvent } from './analytics'; import { showErrorDialog } from './errors'; -import { getServerOrigin } from './server'; +import { getServerOrigin, getServerPort } from './server'; import { setActiveSearchSpaceId } from './active-search-space'; const isDev = !app.isPackaged; -const HOSTED_FRONTEND_URL = process.env.HOSTED_FRONTEND_URL as string; const isMac = process.platform === 'darwin'; const WINDOW_TITLE = 'SurfSense'; +function getHostedFrontendUrl(): string { + return ( + process.env.SURFSENSE_HOSTED_FRONTEND_URL_OVERRIDE || + process.env.HOSTED_FRONTEND_URL || + 'https://surfsense.net' + ); +} + +function getHostedFrontendHosts(): string[] { + try { + const host = new URL(getHostedFrontendUrl()).host; + const sibling = host.startsWith('www.') ? host.slice(4) : `www.${host}`; + return Array.from(new Set([host, sibling])); + } catch { + return []; + } +} + let mainWindow: BrowserWindow | null = null; let isQuitting = false; @@ -68,11 +85,48 @@ export function createMainWindow(initialPath = '/dashboard'): BrowserWindow { return { action: 'deny' }; }); - const filter = { urls: [`${HOSTED_FRONTEND_URL}/*`] }; - session.defaultSession.webRequest.onBeforeRequest(filter, (details, callback) => { - const rewritten = details.url.replace(HOSTED_FRONTEND_URL, getServerOrigin()); - callback({ redirectURL: rewritten }); - }); + const hostedHosts = getHostedFrontendHosts(); + const rewriteFilter = { + urls: hostedHosts.flatMap((h) => [`http://${h}/*`, `https://${h}/*`]), + }; + if (rewriteFilter.urls.length > 0) { + session.defaultSession.webRequest.onBeforeRequest(rewriteFilter, (details, callback) => { + try { + const u = new URL(details.url); + const originalHost = u.host; + const local = new URL(getServerOrigin()); + u.protocol = local.protocol; + u.host = local.host; + trackEvent('desktop_oauth_redirect_intercepted', { + host: originalHost, + path: u.pathname, + rewritten_to_port: getServerPort(), + }); + callback({ redirectURL: u.toString() }); + } catch { + callback({}); + } + }); + } + + // Diagnostic: connector callback landing somewhere other than localhost + // means the rewrite missed and the user is stranded off-app. + session.defaultSession.webRequest.onCompleted( + { urls: ['*://*/dashboard/*/connectors/callback*'] }, + (details) => { + try { + const u = new URL(details.url); + if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') return; + trackEvent('desktop_oauth_redirect_missed', { + host: u.host, + path: u.pathname, + status_code: details.statusCode, + }); + } catch { + // ignore malformed URLs + } + } + ); mainWindow.webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL) => { console.error(`Failed to load ${validatedURL}: ${errorDescription} (${errorCode})`); diff --git a/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts b/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts index 14573066d..304f33a33 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts +++ b/surfsense_web/app/dashboard/[search_space_id]/connectors/callback/route.ts @@ -1,6 +1,5 @@ import { type NextRequest, NextResponse } from "next/server"; - -const OAUTH_RESULT_COOKIE = "connector_oauth_result"; +import { OAUTH_RESULT_COOKIE, type OAuthCallbackResult } from "@/contracts/types/oauth.types"; export async function GET( request: NextRequest, @@ -9,12 +8,13 @@ export async function GET( const { search_space_id } = await params; const searchParams = request.nextUrl.searchParams; - const result = JSON.stringify({ + const payload: OAuthCallbackResult = { success: searchParams.get("success"), error: searchParams.get("error"), connector: searchParams.get("connector"), connectorId: searchParams.get("connectorId"), - }); + }; + const result = JSON.stringify(payload); const redirectUrl = new URL(`/dashboard/${search_space_id}/new-chat`, request.url); diff --git a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx index f003dde1b..17c1dd121 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/team/team-content.tsx @@ -31,7 +31,7 @@ import { deleteMemberMutationAtom, updateMemberMutationAtom, } from "@/atoms/members/members-mutation.atoms"; -import { membersAtom, myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { membersAtom, myAccessAtom, canPerform } from "@/atoms/members/members-query.atoms"; import { AlertDialog, AlertDialogAction, @@ -126,14 +126,9 @@ export function TeamContent({ searchSpaceId }: TeamContentProps) { const { data: access = null, isLoading: accessLoading } = useAtomValue(myAccessAtom); const hasPermission = useCallback( - (permission: string) => { - if (!access) return false; - if (access.is_owner) return true; - return access.permissions?.includes(permission) ?? false; - }, + (permission: string) => canPerform(access, permission), [access] ); - const { data: members = [], isLoading: membersLoading } = useAtomValue(membersAtom); const { mutateAsync: updateMember } = useAtomValue(updateMemberMutationAtom); diff --git a/surfsense_web/atoms/members/members-query.atoms.ts b/surfsense_web/atoms/members/members-query.atoms.ts index c08a7a337..f8e4b2cf6 100644 --- a/surfsense_web/atoms/members/members-query.atoms.ts +++ b/surfsense_web/atoms/members/members-query.atoms.ts @@ -39,3 +39,38 @@ export const myAccessAtom = atomWithQuery((get) => { }, }; }); + +/** + * Helper function to check if the current user has a specific permission. + * + * @param access - The access object from useAtomValue(myAccessAtom) + * @param permission - The permission string to check + * @returns boolean indicating if the user has the permission + * + * @example + * const access = useAtomValue(myAccessAtom); + * if (canPerform(access, 'manage_members')) { ... } + */ +export function canPerform( + access: { is_owner: boolean; permissions?: string[] } | null | undefined, + permission: string +): boolean { + if (!access) return false; + if (access.is_owner) return true; + return access.permissions?.includes(permission) ?? false; +} + +/** + * Hook wrapper for canPerform that reads from myAccessAtom internally. + * Use this if you want to avoid calling useAtomValue(myAccessAtom) separately. + * + * @param permission - The permission string to check + * @returns boolean indicating if the user has the permission + * + * @example + * const canManageMembers = usePermissionGate('manage_members'); + */ +export function usePermissionGate(permission: string): boolean { + const access = useAtomValue(myAccessAtom); + return canPerform(access, permission); +} diff --git a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts index 2f9605ea7..01a911d70 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/constants/connector-constants.ts @@ -303,110 +303,16 @@ export const AUTO_INDEX_CONNECTOR_TYPES = new Set(Object.keys(AUTO_INDEX // `lib/posthog/events.ts` or per-connector tracking code. // ============================================================================ -export type ConnectorTelemetryGroup = "oauth" | "composio" | "crawler" | "other" | "unknown"; - -export interface ConnectorTelemetryMeta { - connector_type: string; - connector_title: string; - connector_group: ConnectorTelemetryGroup; - is_oauth: boolean; -} - -const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap = (() => { - const map = new Map(); - - for (const c of OAUTH_CONNECTORS) { - map.set(c.connectorType, { - connector_type: c.connectorType, - connector_title: c.title, - connector_group: "oauth", - is_oauth: true, - }); - } - for (const c of COMPOSIO_CONNECTORS) { - map.set(c.connectorType, { - connector_type: c.connectorType, - connector_title: c.title, - connector_group: "composio", - is_oauth: true, - }); - } - for (const c of CRAWLERS) { - map.set(c.connectorType, { - connector_type: c.connectorType, - connector_title: c.title, - connector_group: "crawler", - is_oauth: false, - }); - } - for (const c of OTHER_CONNECTORS) { - map.set(c.connectorType, { - connector_type: c.connectorType, - connector_title: c.title, - connector_group: "other", - is_oauth: false, - }); - } - - return map; -})(); - -/** - * Returns telemetry metadata for a connector_type, or a minimal "unknown" - * record so tracking never no-ops for connectors that exist in the backend - * but were forgotten in the UI registry. - */ -export function getConnectorTelemetryMeta(connectorType: string): ConnectorTelemetryMeta { - const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); - if (hit) return hit; - - return { - connector_type: connectorType, - connector_title: connectorType, - connector_group: "unknown", - is_oauth: false, - }; -} - -// ============================================================================= -// REAUTH ENDPOINTS -// ============================================================================= - -/** - * Legacy (non-MCP) OAuth reauth endpoints, keyed by connector type. - * These are used for connectors that were NOT created via MCP OAuth. - */ -export const LEGACY_REAUTH_ENDPOINTS: Partial> = { - [EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth", - [EnumConnectorName.JIRA_CONNECTOR]: "/api/v1/auth/jira/connector/reauth", - [EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth", - [EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth", - [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth", - [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth", - [EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", - [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", - [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/composio/connector/reauth", - [EnumConnectorName.ONEDRIVE_CONNECTOR]: "/api/v1/auth/onedrive/connector/reauth", - [EnumConnectorName.DROPBOX_CONNECTOR]: "/api/v1/auth/dropbox/connector/reauth", - [EnumConnectorName.CONFLUENCE_CONNECTOR]: "/api/v1/auth/confluence/connector/reauth", - [EnumConnectorName.TEAMS_CONNECTOR]: "/api/v1/auth/teams/connector/reauth", - [EnumConnectorName.DISCORD_CONNECTOR]: "/api/v1/auth/discord/connector/reauth", -}; - -/** - * Resolve the reauth endpoint for a connector. - * - * MCP OAuth connectors (those with ``config.mcp_service``) dynamically build - * the URL from the service key. Legacy OAuth connectors fall back to the - * static ``LEGACY_REAUTH_ENDPOINTS`` map. - */ -export function getReauthEndpoint(connector: SearchSourceConnector): string | undefined { - const mcpService = connector.config?.mcp_service as string | undefined; - if (mcpService) { - return `/api/v1/auth/mcp/${mcpService}/connector/reauth`; - } - return LEGACY_REAUTH_ENDPOINTS[connector.connector_type]; -} +// Telemetry types & helpers are now defined in `@/lib/connector-telemetry`. +// Re-exported here for backward compatibility with existing imports. +export type { + ConnectorTelemetryGroup, + ConnectorTelemetryMeta, +} from "@/lib/connector-telemetry"; +export { + getConnectorTelemetryMeta, + getReauthEndpoint, +} from "@/lib/connector-telemetry"; // Re-export IndexingConfigState from schemas for backward compatibility export type { IndexingConfigState } from "./connector-popup.schemas"; diff --git a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts index d1d675ad1..25ab82e2e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts +++ b/surfsense_web/components/assistant-ui/connector-popup/hooks/use-connector-dialog.ts @@ -14,7 +14,9 @@ import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-quer import { EnumConnectorName } from "@/contracts/enums/connector"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { searchSourceConnector } from "@/contracts/types/connector.types"; +import { OAUTH_RESULT_COOKIE, parseOAuthCallbackResult } from "@/contracts/types/oauth.types"; import { authenticatedFetch } from "@/lib/auth-utils"; +import { BACKEND_URL } from "@/lib/env-config"; import { trackConnectorConnected, trackConnectorDeleted, @@ -36,15 +38,12 @@ import { OAUTH_CONNECTORS, OTHER_CONNECTORS, } from "../constants/connector-constants"; - import { dateRangeSchema, frequencyMinutesSchema, parseOAuthAuthResponse, validateIndexingConfigState, } from "../constants/connector-popup.schemas"; -import { BACKEND_URL } from "@/lib/env-config"; -const OAUTH_RESULT_COOKIE = "connector_oauth_result"; function readOAuthResultCookie(): string | null { const match = document.cookie @@ -211,17 +210,8 @@ export const useConnectorDialog = () => { if (!raw || !searchSpaceId) return; clearOAuthResultCookie(); - let result: { - success: string | null; - error: string | null; - connector: string | null; - connectorId: string | null; - }; - try { - result = JSON.parse(raw); - } catch { - return; - } + const result = parseOAuthCallbackResult(raw); + if (!result) return; if (result.error) { const oauthConnector = result.connector diff --git a/surfsense_web/components/assistant-ui/thread.tsx b/surfsense_web/components/assistant-ui/thread.tsx index 6876ce23e..c4f6fed05 100644 --- a/surfsense_web/components/assistant-ui/thread.tsx +++ b/surfsense_web/components/assistant-ui/thread.tsx @@ -105,7 +105,7 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { useElectronAPI } from "@/hooks/use-platform"; import { captureDisplayToPngDataUrl } from "@/lib/chat/display-media-capture"; import { getMentionDocKey } from "@/lib/chat/mention-doc-key"; -import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; +import { slideoutOpenedTickAtom } from "@/lib/layout-events"; import { cn } from "@/lib/utils"; const COMPOSER_PLACEHOLDER = "Ask anything, type / for prompts, type @ to mention docs"; @@ -478,15 +478,18 @@ const Composer: FC = () => { editorRef.current?.focus(); }, [isDesktop, showDocumentPopover, showPromptPicker, threadId]); - // Close document picker when a slide-out panel (inbox, etc.) opens. + // Close document picker when a sidebar slide-out panel (inbox, etc.) opens. + // React only on changes to the tick — comparing against the previously-seen + // value preserves the one-shot semantics of the prior window-event approach + // (no retroactive close on mount if a panel had already opened earlier). + const slideoutOpenedTick = useAtomValue(slideoutOpenedTickAtom); + const lastSeenSlideoutTickRef = useRef(slideoutOpenedTick); useEffect(() => { - const handler = () => { - setShowDocumentPopover(false); - setMentionQuery(""); - }; - window.addEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler); - return () => window.removeEventListener(SLIDEOUT_PANEL_OPENED_EVENT, handler); - }, []); + if (lastSeenSlideoutTickRef.current === slideoutOpenedTick) return; + lastSeenSlideoutTickRef.current = slideoutOpenedTick; + setShowDocumentPopover(false); + setMentionQuery(""); + }, [slideoutOpenedTick]); // Sync editor text into assistant-ui's composer and mirror the chip // atom from the editor's reported ``docs``. The editor is the diff --git a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx index 3fa4dd5d3..52b2cf998 100644 --- a/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx +++ b/surfsense_web/components/layout/ui/sidebar/SidebarSlideOutPanel.tsx @@ -1,9 +1,10 @@ "use client"; +import { useSetAtom } from "jotai"; import { AnimatePresence, motion } from "motion/react"; import { useCallback, useEffect } from "react"; import { useIsMobile } from "@/hooks/use-mobile"; -import { SLIDEOUT_PANEL_OPENED_EVENT } from "@/lib/layout-events"; +import { slideoutOpenedTickAtom } from "@/lib/layout-events"; interface SidebarSlideOutPanelProps { open: boolean; @@ -29,12 +30,13 @@ export function SidebarSlideOutPanel({ children, }: SidebarSlideOutPanelProps) { const isMobile = useIsMobile(); + const bumpSlideoutOpenedTick = useSetAtom(slideoutOpenedTickAtom); useEffect(() => { if (open) { - window.dispatchEvent(new Event(SLIDEOUT_PANEL_OPENED_EVENT)); + bumpSlideoutOpenedTick((tick) => tick + 1); } - }, [open]); + }, [open, bumpSlideoutOpenedTick]); const handleEscape = useCallback( (e: KeyboardEvent) => { diff --git a/surfsense_web/components/settings/prompt-config-manager.tsx b/surfsense_web/components/settings/prompt-config-manager.tsx index 871098bc9..71cfcb971 100644 --- a/surfsense_web/components/settings/prompt-config-manager.tsx +++ b/surfsense_web/components/settings/prompt-config-manager.tsx @@ -1,19 +1,19 @@ "use client"; import { useQuery } from "@tanstack/react-query"; +import { useAtomValue } from "jotai"; import { AlertTriangle, Info } from "lucide-react"; import { useEffect, useState } from "react"; import { toast } from "sonner"; +import { updateSearchSpaceMutationAtom } from "@/atoms/search-spaces/search-space-mutation.atoms"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; import { Label } from "@/components/ui/label"; import { Skeleton } from "@/components/ui/skeleton"; import { Textarea } from "@/components/ui/textarea"; import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; -import { authenticatedFetch } from "@/lib/auth-utils"; import { cacheKeys } from "@/lib/query-client/cache-keys"; import { Spinner } from "../ui/spinner"; -import { BACKEND_URL } from "@/lib/env-config"; interface PromptConfigManagerProps { searchSpaceId: number; @@ -23,15 +23,17 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) const { data: searchSpace, isLoading: loading, - refetch: fetchSearchSpace, } = useQuery({ queryKey: cacheKeys.searchSpaces.detail(searchSpaceId.toString()), queryFn: () => searchSpacesApiService.getSearchSpace({ id: searchSpaceId }), enabled: !!searchSpaceId, }); + const { mutateAsync: updateSearchSpace, isPending: isSaving } = useAtomValue( + updateSearchSpaceMutationAtom + ); + const [customInstructions, setCustomInstructions] = useState(""); - const [saving, setSaving] = useState(false); const hasSearchSpace = !!searchSpace; const searchSpaceInstructions = searchSpace?.qna_custom_instructions; @@ -48,34 +50,16 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) const handleSave = async () => { try { - setSaving(true); - - const payload = { - qna_custom_instructions: customInstructions.trim() || "", - }; - - const response = await authenticatedFetch( - `${BACKEND_URL}/api/v1/searchspaces/${searchSpaceId}`, - { - method: "PUT", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(payload), - } - ); - - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.detail || "Failed to save system instructions"); - } - + await updateSearchSpace({ + id: searchSpaceId, + data: { qna_custom_instructions: customInstructions.trim() || "" }, + }); toast.success("System instructions saved successfully"); - - await fetchSearchSpace(); } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Failed to save system instructions"; console.error("Error saving system instructions:", error); - toast.error(error instanceof Error ? error.message : "Failed to save system instructions"); - } finally { - setSaving(false); + toast.error(message); } }; @@ -184,11 +168,11 @@ export function PromptConfigManager({ searchSpaceId }: PromptConfigManagerProps) diff --git a/surfsense_web/components/settings/roles-manager.tsx b/surfsense_web/components/settings/roles-manager.tsx index ee32f6e69..88595e748 100644 --- a/surfsense_web/components/settings/roles-manager.tsx +++ b/surfsense_web/components/settings/roles-manager.tsx @@ -26,7 +26,7 @@ import { } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; -import { myAccessAtom } from "@/atoms/members/members-query.atoms"; +import { canPerform, myAccessAtom } from "@/atoms/members/members-query.atoms"; import { permissionsAtom } from "@/atoms/permissions/permissions-query.atoms"; import { createRoleMutationAtom, @@ -257,11 +257,7 @@ export function RolesManager({ searchSpaceId }: { searchSpaceId: number }) { const { data: access = null } = useAtomValue(myAccessAtom); const hasPermission = useCallback( - (permission: string) => { - if (!access) return false; - if (access.is_owner) return true; - return access.permissions?.includes(permission) ?? false; - }, + (permission: string) => canPerform(access, permission), [access] ); diff --git a/surfsense_web/contracts/types/oauth.types.ts b/surfsense_web/contracts/types/oauth.types.ts new file mode 100644 index 000000000..8b3854cf8 --- /dev/null +++ b/surfsense_web/contracts/types/oauth.types.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +export const OAUTH_RESULT_COOKIE = "connector_oauth_result"; + +/** + * Schema for the payload written to the `connector_oauth_result` cookie by the + * OAuth callback route and read back by the connector dialog hook. + */ +export const oauthCallbackResultSchema = z.object({ + success: z.string().nullable(), + error: z.string().nullable(), + connector: z.string().nullable(), + connectorId: z.string().nullable(), +}); + +export type OAuthCallbackResult = z.infer; + +/** + * Safely decode and validate the OAuth callback cookie value. Returns `null` + * when the value is not valid JSON or does not match the expected shape. + */ +export function parseOAuthCallbackResult(raw: string): OAuthCallbackResult | null { + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + const result = oauthCallbackResultSchema.safeParse(parsed); + return result.success ? result.data : null; +} diff --git a/surfsense_web/lib/connector-telemetry.ts b/surfsense_web/lib/connector-telemetry.ts new file mode 100644 index 000000000..ef1b3de32 --- /dev/null +++ b/surfsense_web/lib/connector-telemetry.ts @@ -0,0 +1,149 @@ +import { EnumConnectorName } from "@/contracts/enums/connector"; +import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { + OAUTH_CONNECTORS, + COMPOSIO_CONNECTORS, + CRAWLERS, + OTHER_CONNECTORS, +} from "@/components/assistant-ui/connector-popup/constants/connector-constants"; + +// ============================================================================= +// Connector Telemetry Types & Registry +// ============================================================================= + +export type ConnectorTelemetryGroup = + | "oauth" + | "composio" + | "crawler" + | "other" + | "unknown"; + +export interface ConnectorTelemetryMeta { + connector_type: string; + connector_title: string; + connector_group: ConnectorTelemetryGroup; + is_oauth: boolean; +} + +/** + * Single source of truth for "what does this connector_type look like in + * analytics?". Any connector added to the lists above is automatically + * picked up here, so adding a new integration does NOT require touching + * `lib/posthog/events.ts` or per-connector tracking code. + */ +const CONNECTOR_TELEMETRY_REGISTRY: ReadonlyMap< + string, + ConnectorTelemetryMeta +> = (() => { + const map = new Map(); + + for (const c of OAUTH_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "oauth", + is_oauth: true, + }); + } + for (const c of COMPOSIO_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "composio", + is_oauth: true, + }); + } + for (const c of CRAWLERS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "crawler", + is_oauth: false, + }); + } + for (const c of OTHER_CONNECTORS) { + map.set(c.connectorType, { + connector_type: c.connectorType, + connector_title: c.title, + connector_group: "other", + is_oauth: false, + }); + } + + return map; +})(); + +/** + * Returns telemetry metadata for a connector_type, or a minimal "unknown" + * record so tracking never no-ops for connectors that exist in the backend + * but were forgotten in the UI registry. + */ +export function getConnectorTelemetryMeta( + connectorType: string, +): ConnectorTelemetryMeta { + const hit = CONNECTOR_TELEMETRY_REGISTRY.get(connectorType); + if (hit) return hit; + + return { + connector_type: connectorType, + connector_title: connectorType, + connector_group: "unknown", + is_oauth: false, + }; +} + +// ============================================================================= +// Reauth Endpoint Resolution +// ============================================================================= + +/** + * Legacy (non-MCP) OAuth reauth endpoints, keyed by connector type. + * These are used for connectors that were NOT created via MCP OAuth. + */ +const LEGACY_REAUTH_ENDPOINTS: Partial> = { + [EnumConnectorName.LINEAR_CONNECTOR]: + "/api/v1/auth/linear/connector/reauth", + [EnumConnectorName.JIRA_CONNECTOR]: + "/api/v1/auth/jira/connector/reauth", + [EnumConnectorName.NOTION_CONNECTOR]: + "/api/v1/auth/notion/connector/reauth", + [EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: + "/api/v1/auth/google/drive/connector/reauth", + [EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: + "/api/v1/auth/google/gmail/connector/reauth", + [EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: + "/api/v1/auth/google/calendar/connector/reauth", + [EnumConnectorName.COMPOSIO_GOOGLE_DRIVE_CONNECTOR]: + "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.COMPOSIO_GMAIL_CONNECTOR]: + "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.COMPOSIO_GOOGLE_CALENDAR_CONNECTOR]: + "/api/v1/auth/composio/connector/reauth", + [EnumConnectorName.ONEDRIVE_CONNECTOR]: + "/api/v1/auth/onedrive/connector/reauth", + [EnumConnectorName.DROPBOX_CONNECTOR]: + "/api/v1/auth/dropbox/connector/reauth", + [EnumConnectorName.CONFLUENCE_CONNECTOR]: + "/api/v1/auth/confluence/connector/reauth", + [EnumConnectorName.TEAMS_CONNECTOR]: + "/api/v1/auth/teams/connector/reauth", + [EnumConnectorName.DISCORD_CONNECTOR]: + "/api/v1/auth/discord/connector/reauth", +}; + +/** + * Resolve the reauth endpoint for a connector. + * + * MCP OAuth connectors (those with ``config.mcp_service``) dynamically build + * the URL from the service key. Legacy OAuth connectors fall back to the + * static ``LEGACY_REAUTH_ENDPOINTS`` map. + */ +export function getReauthEndpoint( + connector: SearchSourceConnector, +): string | undefined { + const mcpService = connector.config?.mcp_service as string | undefined; + if (mcpService) { + return `/api/v1/auth/mcp/${mcpService}/connector/reauth`; + } + return LEGACY_REAUTH_ENDPOINTS[connector.connector_type]; +} diff --git a/surfsense_web/lib/layout-events.ts b/surfsense_web/lib/layout-events.ts index 45c52f7a4..755329c41 100644 --- a/surfsense_web/lib/layout-events.ts +++ b/surfsense_web/lib/layout-events.ts @@ -1 +1,11 @@ -export const SLIDEOUT_PANEL_OPENED_EVENT = "slideout-panel-opened"; +import { atom } from "jotai"; + +/** + * Tick counter that increments each time a sidebar slide-out panel opens. + * Consumers read this with `useAtomValue` and store the last-seen value in + * a ref so the effect fires only when the tick changes. This preserves the + * one-shot semantics of the previous window-event implementation: a + * subscriber that mounts after a panel has already opened does not + * retroactively re-trigger. + */ +export const slideoutOpenedTickAtom = atom(0); diff --git a/surfsense_web/lib/posthog/events.ts b/surfsense_web/lib/posthog/events.ts index 127685fe8..687d589f9 100644 --- a/surfsense_web/lib/posthog/events.ts +++ b/surfsense_web/lib/posthog/events.ts @@ -1,5 +1,5 @@ -import posthog from "posthog-js"; -import { getConnectorTelemetryMeta } from "@/components/assistant-ui/connector-popup/constants/connector-constants"; +import type { ConnectorTelemetryMeta } from "@/lib/connector-telemetry"; +import { getConnectorTelemetryMeta } from "@/lib/connector-telemetry"; import type { ChatErrorKind, ChatErrorSeverity, ChatFlow } from "@/lib/chat/chat-error-classifier"; /**