Merge remote-tracking branch 'upstream/dev' into fix/electron-nextjs
|
|
@ -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 `<priority_documents>` 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
BIN
surfsense_desktop/assets/icons/1024x1024.png
Normal file
|
After Width: | Height: | Size: 1.5 MiB |
BIN
surfsense_desktop/assets/icons/128x128.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
surfsense_desktop/assets/icons/16x16.png
Normal file
|
After Width: | Height: | Size: 841 B |
BIN
surfsense_desktop/assets/icons/256x256.png
Normal file
|
After Width: | Height: | Size: 99 KiB |
BIN
surfsense_desktop/assets/icons/32x32.png
Normal file
|
After Width: | Height: | Size: 2.7 KiB |
BIN
surfsense_desktop/assets/icons/48x48.png
Normal file
|
After Width: | Height: | Size: 5.4 KiB |
BIN
surfsense_desktop/assets/icons/512x512.png
Normal file
|
After Width: | Height: | Size: 371 KiB |
BIN
surfsense_desktop/assets/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 9 KiB |
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ export async function startNextServer(): Promise<void> {
|
|||
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',
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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})`);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -303,110 +303,16 @@ export const AUTO_INDEX_CONNECTOR_TYPES = new Set<string>(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<string, ConnectorTelemetryMeta> = (() => {
|
||||
const map = new Map<string, ConnectorTelemetryMeta>();
|
||||
|
||||
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<Record<string, string>> = {
|
||||
[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";
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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)
|
|||
<Button
|
||||
type="submit"
|
||||
variant="outline"
|
||||
disabled={!hasChanges || saving}
|
||||
disabled={!hasChanges || isSaving}
|
||||
className="gap-2 bg-white text-black hover:bg-accent hover:text-accent-foreground dark:bg-white dark:text-black"
|
||||
>
|
||||
{saving ? <Spinner size="sm" /> : null}
|
||||
{saving ? "Saving" : "Save Instructions"}
|
||||
{isSaving ? <Spinner size="sm" /> : null}
|
||||
{isSaving ? "Saving" : "Save Instructions"}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
);
|
||||
|
||||
|
|
|
|||
31
surfsense_web/contracts/types/oauth.types.ts
Normal file
|
|
@ -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<typeof oauthCallbackResultSchema>;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
149
surfsense_web/lib/connector-telemetry.ts
Normal file
|
|
@ -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<string, ConnectorTelemetryMeta>();
|
||||
|
||||
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<Record<string, string>> = {
|
||||
[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];
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
/**
|
||||
|
|
|
|||