diff --git a/surfsense_backend/app/routes/onedrive_add_connector_route.py b/surfsense_backend/app/routes/onedrive_add_connector_route.py index 19bcbe6ff..64f5b2461 100644 --- a/surfsense_backend/app/routes/onedrive_add_connector_route.py +++ b/surfsense_backend/app/routes/onedrive_add_connector_route.py @@ -5,7 +5,8 @@ Endpoints: - GET /auth/onedrive/connector/add - Initiate OAuth - GET /auth/onedrive/connector/callback - Handle OAuth callback - GET /auth/onedrive/connector/reauth - Re-authenticate existing connector -- GET /connectors/{connector_id}/onedrive/folders - List folder contents +- GET /connectors/{connector_id}/onedrive/folders - List folder contents (legacy custom browser) +- GET /connectors/{connector_id}/onedrive/picker-token - Get SharePoint token for File Picker v8 """ import logging @@ -395,6 +396,121 @@ async def list_onedrive_folders( raise HTTPException(status_code=500, detail=f"Failed to list OneDrive contents: {e!s}") from e +@router.get("/connectors/{connector_id}/onedrive/picker-token") +async def get_onedrive_picker_token( + connector_id: int, + resource: str | None = None, + session: AsyncSession = Depends(get_async_session), + user: User = Depends(current_active_user), +): + """Get an access token scoped for the OneDrive File Picker v8. + + The picker requires SharePoint-audience tokens, not Graph tokens. + If *resource* is omitted the user's OneDrive root URL is resolved via + Graph and used as the resource. + """ + try: + result = await session.execute( + select(SearchSourceConnector).filter( + SearchSourceConnector.id == connector_id, + SearchSourceConnector.user_id == user.id, + SearchSourceConnector.connector_type == SearchSourceConnectorType.ONEDRIVE_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + raise HTTPException(status_code=404, detail="OneDrive connector not found or access denied") + + token_encryption = get_token_encryption() + is_encrypted = connector.config.get("_token_encrypted", False) + + # Resolve the SharePoint base URL when the caller doesn't provide one + if not resource: + access_token = connector.config.get("access_token") + if is_encrypted and access_token: + access_token = token_encryption.decrypt_token(access_token) + + # Refresh the Graph token if it has expired + expires_at_str = connector.config.get("expires_at") + if expires_at_str: + from dateutil.parser import parse as parse_date + if datetime.now(UTC) >= parse_date(expires_at_str): + connector = await refresh_onedrive_token(session, connector) + access_token = connector.config.get("access_token") + if connector.config.get("_token_encrypted") and access_token: + access_token = token_encryption.decrypt_token(access_token) + + async with httpx.AsyncClient() as client: + drive_resp = await client.get( + "https://graph.microsoft.com/v1.0/me/drive/root", + headers={"Authorization": f"Bearer {access_token}"}, + timeout=30.0, + ) + if drive_resp.status_code != 200: + raise HTTPException( + status_code=500, + detail="Failed to resolve OneDrive base URL from Graph API", + ) + from urllib.parse import urlparse + web_url = drive_resp.json().get("webUrl", "") + parsed = urlparse(web_url) + resource = f"{parsed.scheme}://{parsed.hostname}" + + # Exchange the refresh token for a SharePoint-audience token + refresh_token = connector.config.get("refresh_token") + if is_encrypted and refresh_token: + refresh_token = token_encryption.decrypt_token(refresh_token) + if not refresh_token: + raise HTTPException(status_code=400, detail="No refresh token available") + + token_data = { + "client_id": config.MICROSOFT_CLIENT_ID, + "client_secret": config.MICROSOFT_CLIENT_SECRET, + "grant_type": "refresh_token", + "refresh_token": refresh_token, + "scope": f"{resource}/.default", + } + async with httpx.AsyncClient() as client: + token_response = await client.post( + TOKEN_URL, + data=token_data, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + timeout=30.0, + ) + + if token_response.status_code != 200: + error_detail = "Failed to acquire picker token" + try: + error_json = token_response.json() + error_detail = error_json.get("error_description", error_detail) + except Exception: + pass + logger.error("Picker token exchange failed for connector %s: %s", connector_id, error_detail) + raise HTTPException(status_code=400, detail=error_detail) + + token_json = token_response.json() + + # Persist new refresh token when Microsoft rotates it + new_refresh = token_json.get("refresh_token") + if new_refresh: + cfg = dict(connector.config) + cfg["refresh_token"] = token_encryption.encrypt_token(new_refresh) + connector.config = cfg + flag_modified(connector, "config") + await session.commit() + + return { + "access_token": token_json["access_token"], + "base_url": resource, + } + + except HTTPException: + raise + except Exception as e: + logger.error("Error getting OneDrive picker token: %s", str(e), exc_info=True) + raise HTTPException(status_code=500, detail=f"Failed to get picker token: {e!s}") from e + + async def refresh_onedrive_token( session: AsyncSession, connector: SearchSourceConnector ) -> SearchSourceConnector: diff --git a/surfsense_web/components/assistant-ui/connector-popup.tsx b/surfsense_web/components/assistant-ui/connector-popup.tsx index ae50ed7a4..b725ab703 100644 --- a/surfsense_web/components/assistant-ui/connector-popup.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup.tsx @@ -22,6 +22,10 @@ import { Tabs, TabsContent } from "@/components/ui/tabs"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; import { useConnectorsSync } from "@/hooks/use-connectors-sync"; import { PICKER_CLOSE_EVENT, PICKER_OPEN_EVENT } from "@/hooks/use-google-picker"; +import { + ONEDRIVE_PICKER_CLOSE_EVENT, + ONEDRIVE_PICKER_OPEN_EVENT, +} from "@/hooks/use-onedrive-picker"; import { cn } from "@/lib/utils"; import { ConnectorDialogHeader } from "./connector-popup/components/connector-dialog-header"; import { ConnectorConnectView } from "./connector-popup/connector-configs/views/connector-connect-view"; @@ -149,9 +153,13 @@ export const ConnectorIndicator = forwardRef setPickerOpen(false); window.addEventListener(PICKER_OPEN_EVENT, onOpen); window.addEventListener(PICKER_CLOSE_EVENT, onClose); + window.addEventListener(ONEDRIVE_PICKER_OPEN_EVENT, onOpen); + window.addEventListener(ONEDRIVE_PICKER_CLOSE_EVENT, onClose); return () => { window.removeEventListener(PICKER_OPEN_EVENT, onOpen); window.removeEventListener(PICKER_CLOSE_EVENT, onClose); + window.removeEventListener(ONEDRIVE_PICKER_OPEN_EVENT, onOpen); + window.removeEventListener(ONEDRIVE_PICKER_CLOSE_EVENT, onClose); }; }, []); @@ -340,10 +348,11 @@ export const ConnectorIndicator = forwardRef { const cfg = connectorConfig || editingConnector.config; - const isDrive = - editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || - editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR"; - const hasDriveItems = isDrive + const isDriveOrOneDrive = + editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR" || + editingConnector.connector_type === "ONEDRIVE_CONNECTOR"; + const hasDriveItems = isDriveOrOneDrive ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 || ((cfg?.selected_files as unknown[]) ?? []).length > 0 : true; diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx index eda686596..65df4d01e 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/onedrive-config.tsx @@ -1,12 +1,10 @@ "use client"; import { - ChevronRight, File, FileSpreadsheet, FileText, FolderClosed, - FolderOpen, Image, Presentation, X, @@ -14,7 +12,6 @@ import { import type { FC } from "react"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; -import { Checkbox } from "@/components/ui/checkbox"; import { Label } from "@/components/ui/label"; import { Select, @@ -25,12 +22,13 @@ import { } from "@/components/ui/select"; import { Spinner } from "@/components/ui/spinner"; import { Switch } from "@/components/ui/switch"; -import { authenticatedFetch } from "@/lib/auth-utils"; +import { type OneDrivePickerResult, useOneDrivePicker } from "@/hooks/use-onedrive-picker"; import type { ConnectorConfigProps } from "../index"; interface SelectedItem { id: string; name: string; + driveId?: string; } interface IndexingOptions { @@ -39,17 +37,6 @@ interface IndexingOptions { include_subfolders: boolean; } -interface OneDriveItem { - id: string; - name: string; - isFolder: boolean; - size?: number; - lastModifiedDateTime?: string; - file?: { mimeType: string }; - folder?: { childCount: number }; - webUrl?: string; -} - const DEFAULT_INDEXING_OPTIONS: IndexingOptions = { max_files_per_folder: 100, incremental_sync: true, @@ -83,115 +70,62 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh const [selectedFiles, setSelectedFiles] = useState(existingFiles); const [indexingOptions, setIndexingOptions] = useState(existingIndexingOptions); - const [browserOpen, setBrowserOpen] = useState(false); - const [browseItems, setBrowseItems] = useState([]); - const [browseLoading, setBrowseLoading] = useState(false); - const [browseError, setBrowseError] = useState(null); - const [breadcrumbs, setBreadcrumbs] = useState<{ id: string; name: string }[]>([ - { id: "root", name: "My files" }, - ]); - useEffect(() => { const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; const files = (connector.config?.selected_files as SelectedItem[] | undefined) || []; const options = - (connector.config?.indexing_options as IndexingOptions | undefined) || DEFAULT_INDEXING_OPTIONS; + (connector.config?.indexing_options as IndexingOptions | undefined) || + DEFAULT_INDEXING_OPTIONS; setSelectedFolders(folders); setSelectedFiles(files); setIndexingOptions(options); }, [connector.config]); - const updateConfig = useCallback( - (folders: SelectedItem[], files: SelectedItem[], options: IndexingOptions) => { - if (onConfigChange) { - onConfigChange({ - ...connector.config, - selected_folders: folders, - selected_files: files, - indexing_options: options, - }); - } + const updateConfig = ( + folders: SelectedItem[], + files: SelectedItem[], + options: IndexingOptions, + ) => { + if (onConfigChange) { + onConfigChange({ + ...connector.config, + selected_folders: folders, + selected_files: files, + indexing_options: options, + }); + } + }; + + const handlePicked = useCallback( + (result: OneDrivePickerResult) => { + const folders = result.folders.map((f) => ({ id: f.id, name: f.name, driveId: f.driveId })); + const files = result.files.map((f) => ({ id: f.id, name: f.name, driveId: f.driveId })); + setSelectedFolders(folders); + setSelectedFiles(files); + updateConfig(folders, files, indexingOptions); }, - [onConfigChange, connector.config], + // eslint-disable-next-line react-hooks/exhaustive-deps + [indexingOptions, connector.config], ); - const fetchFolderContents = useCallback( - async (parentId: string) => { - setBrowseLoading(true); - setBrowseError(null); - try { - const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; - const url = `${backendUrl}/api/v1/connectors/${connector.id}/onedrive/folders?parent_id=${encodeURIComponent(parentId)}`; - const response = await authenticatedFetch(url); - if (!response.ok) { - const data = await response.json().catch(() => ({})); - throw new Error(data.detail || `Failed to load folder contents (${response.status})`); - } - const data = await response.json(); - setBrowseItems(data.items || []); - } catch (err: unknown) { - const message = err instanceof Error ? err.message : "Failed to load folder contents"; - setBrowseError(message); - } finally { - setBrowseLoading(false); - } - }, - [connector.id], - ); + const { + openPicker, + loading: pickerLoading, + error: pickerError, + } = useOneDrivePicker({ + connectorId: connector.id, + onPicked: handlePicked, + }); - const handleOpenBrowser = useCallback(() => { - setBrowserOpen(true); - setBreadcrumbs([{ id: "root", name: "My files" }]); - fetchFolderContents("root"); - }, [fetchFolderContents]); + const isAuthExpired = + connector.config?.auth_expired === true || + (!!pickerError && pickerError.toLowerCase().includes("authentication expired")); - const handleNavigateFolder = useCallback( - (folderId: string, folderName: string) => { - setBreadcrumbs((prev) => [...prev, { id: folderId, name: folderName }]); - fetchFolderContents(folderId); - }, - [fetchFolderContents], - ); - - const handleBreadcrumbClick = useCallback( - (index: number) => { - const newBreadcrumbs = breadcrumbs.slice(0, index + 1); - setBreadcrumbs(newBreadcrumbs); - fetchFolderContents(newBreadcrumbs[newBreadcrumbs.length - 1].id); - }, - [breadcrumbs, fetchFolderContents], - ); - - const isItemSelected = useCallback( - (item: OneDriveItem) => { - if (item.isFolder) { - return selectedFolders.some((f) => f.id === item.id); - } - return selectedFiles.some((f) => f.id === item.id); - }, - [selectedFolders, selectedFiles], - ); - - const handleToggleItem = useCallback( - (item: OneDriveItem) => { - if (item.isFolder) { - const exists = selectedFolders.some((f) => f.id === item.id); - const newFolders = exists - ? selectedFolders.filter((f) => f.id !== item.id) - : [...selectedFolders, { id: item.id, name: item.name }]; - setSelectedFolders(newFolders); - updateConfig(newFolders, selectedFiles, indexingOptions); - } else { - const exists = selectedFiles.some((f) => f.id === item.id); - const newFiles = exists - ? selectedFiles.filter((f) => f.id !== item.id) - : [...selectedFiles, { id: item.id, name: item.name }]; - setSelectedFiles(newFiles); - updateConfig(selectedFolders, newFiles, indexingOptions); - } - }, - [selectedFolders, selectedFiles, indexingOptions, updateConfig], - ); + const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => { + const newOptions = { ...indexingOptions, [key]: value }; + setIndexingOptions(newOptions); + updateConfig(selectedFolders, selectedFiles, newOptions); + }; const handleRemoveFolder = (folderId: string) => { const newFolders = selectedFolders.filter((f) => f.id !== folderId); @@ -205,13 +139,6 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh updateConfig(selectedFolders, newFiles, indexingOptions); }; - const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => { - const newOptions = { ...indexingOptions, [key]: value }; - setIndexingOptions(newOptions); - updateConfig(selectedFolders, selectedFiles, newOptions); - }; - - const isAuthExpired = connector.config?.auth_expired === true; const totalSelected = selectedFolders.length + selectedFiles.length; return ( @@ -221,20 +148,31 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh

Folder & File Selection

- Browse and select specific folders and/or files to index from your OneDrive. + Select specific folders and/or individual files to index.

{totalSelected > 0 && (

- Selected {totalSelected} item{totalSelected > 1 ? "s" : ""} + Selected {totalSelected} item{totalSelected > 1 ? "s" : ""}: {(() => { + const parts: string[] = []; + if (selectedFolders.length > 0) { + parts.push( + `${selectedFolders.length} folder${selectedFolders.length > 1 ? "s" : ""}`, + ); + } + if (selectedFiles.length > 0) { + parts.push(`${selectedFiles.length} file${selectedFiles.length > 1 ? "s" : ""}`); + } + return parts.length > 0 ? `(${parts.join(", ")})` : ""; + })()}

{selectedFolders.map((folder) => (
@@ -243,6 +181,7 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh type="button" onClick={() => handleRemoveFolder(folder.id)} className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors" + aria-label={`Remove ${folder.name}`} > @@ -251,7 +190,7 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh {selectedFiles.map((file) => (
{getFileIconFromName(file.name)} @@ -260,6 +199,7 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh type="button" onClick={() => handleRemoveFile(file.id)} className="shrink-0 p-0.5 hover:bg-muted-foreground/20 rounded transition-colors" + aria-label={`Remove ${file.name}`} > @@ -269,96 +209,23 @@ export const OneDriveConfig: FC = ({ connector, onConfigCh
)} - {!browserOpen ? ( - - ) : ( -
- {/* Breadcrumbs */} -
- {breadcrumbs.map((crumb, index) => ( - - {index > 0 && } - - - ))} -
+ - {/* File list */} -
- {browseLoading ? ( -
- -
- ) : browseError ? ( -
{browseError}
- ) : browseItems.length === 0 ? ( -
This folder is empty
- ) : ( - browseItems.map((item) => ( -
- handleToggleItem(item)} - className="size-3.5" - /> - {item.isFolder ? ( - - ) : ( -
- {getFileIconFromName(item.name)} - {item.name} -
- )} -
- )) - )} -
- -
- -
-
- )} + {pickerError && !isAuthExpired &&

{pickerError}

} {isAuthExpired && (

- Your OneDrive authentication has expired. Please re-authenticate using the button below. + Your OneDrive authentication has expired. Please re-authenticate using the button + below.

)}
diff --git a/surfsense_web/contracts/types/connector.types.ts b/surfsense_web/contracts/types/connector.types.ts index 2204d4e5e..82d509a4b 100644 --- a/surfsense_web/contracts/types/connector.types.ts +++ b/surfsense_web/contracts/types/connector.types.ts @@ -9,6 +9,7 @@ export const searchSourceConnectorTypeEnum = z.enum([ "BAIDU_SEARCH_API", "SLACK_CONNECTOR", "TEAMS_CONNECTOR", + "ONEDRIVE_CONNECTOR", "NOTION_CONNECTOR", "GITHUB_CONNECTOR", "LINEAR_CONNECTOR", diff --git a/surfsense_web/hooks/use-onedrive-picker.ts b/surfsense_web/hooks/use-onedrive-picker.ts new file mode 100644 index 000000000..b5546074a --- /dev/null +++ b/surfsense_web/hooks/use-onedrive-picker.ts @@ -0,0 +1,252 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { authenticatedFetch } from "@/lib/auth-utils"; + +export interface OneDrivePickerItem { + id: string; + name: string; + isFolder: boolean; + driveId?: string; +} + +export interface OneDrivePickerResult { + folders: OneDrivePickerItem[]; + files: OneDrivePickerItem[]; +} + +interface UseOneDrivePickerOptions { + connectorId: number; + onPicked: (result: OneDrivePickerResult) => void; +} + +export const ONEDRIVE_PICKER_OPEN_EVENT = "onedrive-picker-open"; +export const ONEDRIVE_PICKER_CLOSE_EVENT = "onedrive-picker-close"; + +async function fetchPickerToken( + connectorId: number, + resource?: string, +): Promise<{ access_token: string; base_url: string }> { + const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; + const params = new URLSearchParams(); + if (resource) params.set("resource", resource); + const qs = params.toString(); + const url = `${backendUrl}/api/v1/connectors/${connectorId}/onedrive/picker-token${qs ? `?${qs}` : ""}`; + const response = await authenticatedFetch(url); + if (!response.ok) { + const data = await response.json().catch(() => ({})); + throw new Error(data.detail || `Failed to get picker token (${response.status})`); + } + return response.json(); +} + +export function useOneDrivePicker({ connectorId, onPicked }: UseOneDrivePickerOptions) { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const onPickedRef = useRef(onPicked); + onPickedRef.current = onPicked; + const openingRef = useRef(false); + const winRef = useRef(null); + const portRef = useRef(null); + const messageHandlerRef = useRef<((e: MessageEvent) => void) | null>(null); + const pollRef = useRef | null>(null); + + const closePicker = useCallback(() => { + window.dispatchEvent(new Event(ONEDRIVE_PICKER_CLOSE_EVENT)); + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + if (messageHandlerRef.current) { + window.removeEventListener("message", messageHandlerRef.current); + messageHandlerRef.current = null; + } + if (winRef.current && !winRef.current.closed) { + winRef.current.close(); + } + winRef.current = null; + portRef.current = null; + openingRef.current = false; + }, []); + + useEffect(() => { + const onEscape = (e: KeyboardEvent) => { + if (e.key === "Escape" && winRef.current) { + closePicker(); + } + }; + window.addEventListener("keydown", onEscape); + return () => { + window.removeEventListener("keydown", onEscape); + closePicker(); + }; + }, [closePicker]); + + const openPicker = useCallback(async () => { + if (openingRef.current) return; + openingRef.current = true; + setLoading(true); + setError(null); + + try { + const { access_token, base_url } = await fetchPickerToken(connectorId); + + const win = window.open("", "OneDrivePicker", "width=1080,height=680"); + if (!win) { + throw new Error("Popup blocked. Please allow popups for this site."); + } + winRef.current = win; + + const channelId = crypto.randomUUID(); + + const pickerConfig = { + sdk: "8.0", + entry: { oneDrive: { files: {} } }, + authentication: {}, + messaging: { + origin: window.location.origin, + channelId, + }, + selection: { mode: "multiple" }, + typesAndSources: { + mode: "all" as const, + pivots: { oneDrive: true, recent: true }, + }, + }; + + const qs = new URLSearchParams({ + filePicker: JSON.stringify(pickerConfig), + locale: navigator.language || "en-us", + }); + const pickerUrl = `${base_url}/_layouts/15/FilePicker.aspx?${qs}`; + + const form = win.document.createElement("form"); + form.setAttribute("action", pickerUrl); + form.setAttribute("method", "POST"); + const input = win.document.createElement("input"); + input.setAttribute("type", "hidden"); + input.setAttribute("name", "access_token"); + input.setAttribute("value", access_token); + form.appendChild(input); + win.document.body.append(form); + form.submit(); + + const handleMessage = (event: MessageEvent) => { + if (event.source !== win) return; + const msg = event.data; + if (msg?.type !== "initialize" || msg.channelId !== channelId) return; + + const port = event.ports[0]; + portRef.current = port; + + port.addEventListener("message", async (portEvent: MessageEvent) => { + const payload = portEvent.data; + if (payload.type !== "command") return; + + port.postMessage({ type: "acknowledge", id: payload.id }); + + const cmd = payload.data; + switch (cmd.command) { + case "authenticate": { + try { + const result = await fetchPickerToken(connectorId, cmd.resource); + port.postMessage({ + type: "result", + id: payload.id, + data: { result: "token", token: result.access_token }, + }); + } catch (err) { + port.postMessage({ + type: "result", + id: payload.id, + data: { + result: "error", + error: { + code: "unableToObtainToken", + message: err instanceof Error ? err.message : "Token error", + }, + }, + }); + } + break; + } + case "pick": { + const items: Record[] = cmd.items || []; + const folders: OneDrivePickerItem[] = []; + const files: OneDrivePickerItem[] = []; + + for (const item of items) { + const isFolder = + item.folder != null || + (typeof item["@odata.type"] === "string" && + (item["@odata.type"] as string).includes("folder")); + const parentRef = item.parentReference as + | { driveId?: string } + | undefined; + const pickerItem: OneDrivePickerItem = { + id: item.id as string, + name: (item.name as string) || "Untitled", + isFolder, + driveId: parentRef?.driveId, + }; + if (isFolder) { + folders.push(pickerItem); + } else { + files.push(pickerItem); + } + } + + onPickedRef.current({ folders, files }); + port.postMessage({ + type: "result", + id: payload.id, + data: { result: "success" }, + }); + closePicker(); + break; + } + case "close": { + closePicker(); + break; + } + default: { + port.postMessage({ + type: "result", + id: payload.id, + data: { + result: "error", + error: { code: "unsupportedCommand", message: cmd.command }, + }, + }); + break; + } + } + }); + + port.start(); + port.postMessage({ type: "activate" }); + }; + + messageHandlerRef.current = handleMessage; + window.addEventListener("message", handleMessage); + + pollRef.current = setInterval(() => { + if (win.closed) { + closePicker(); + } + }, 500); + + window.dispatchEvent(new Event(ONEDRIVE_PICKER_OPEN_EVENT)); + } catch (err) { + openingRef.current = false; + const msg = err instanceof Error ? err.message : "Failed to open OneDrive Picker"; + setError(msg); + console.error("OneDrive Picker error:", err); + window.dispatchEvent(new Event(ONEDRIVE_PICKER_CLOSE_EVENT)); + } finally { + setLoading(false); + } + }, [connectorId, closePicker]); + + return { openPicker, closePicker, loading, error }; +}