feat: implement OneDrive picker integration and enhance connector functionality with new API endpoints and UI updates

This commit is contained in:
Anish Sarkar 2026-03-28 23:57:46 +05:30
parent e2dd6e61a9
commit ea218b7be6
5 changed files with 457 additions and 212 deletions

View file

@ -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:

View file

@ -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<ConnectorIndicatorHandle, Connector
const onClose = () => 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<ConnectorIndicatorHandle, Connector
onBack={handleBackFromEdit}
onQuickIndex={(() => {
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;

View file

@ -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<ConnectorConfigProps> = ({ connector, onConfigCh
const [selectedFiles, setSelectedFiles] = useState<SelectedItem[]>(existingFiles);
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
const [browserOpen, setBrowserOpen] = useState(false);
const [browseItems, setBrowseItems] = useState<OneDriveItem[]>([]);
const [browseLoading, setBrowseLoading] = useState(false);
const [browseError, setBrowseError] = useState<string | null>(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<ConnectorConfigProps> = ({ 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<ConnectorConfigProps> = ({ connector, onConfigCh
<div className="space-y-1 sm:space-y-2">
<h3 className="font-medium text-sm sm:text-base">Folder & File Selection</h3>
<p className="text-xs sm:text-sm text-muted-foreground">
Browse and select specific folders and/or files to index from your OneDrive.
Select specific folders and/or individual files to index.
</p>
</div>
{totalSelected > 0 && (
<div className="p-2 sm:p-3 bg-muted rounded-lg text-xs sm:text-sm space-y-1 sm:space-y-2">
<p className="font-medium">
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(", ")})` : "";
})()}
</p>
<div className="max-h-20 sm:max-h-24 overflow-y-auto space-y-1">
{selectedFolders.map((folder) => (
<div
key={folder.id}
className="text-xs text-muted-foreground truncate flex items-center gap-1.5"
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={folder.name}
>
<FolderClosed className="size-3.5 shrink-0 text-muted-foreground" />
@ -243,6 +181,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ 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}`}
>
<X className="size-3.5" />
</button>
@ -251,7 +190,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
{selectedFiles.map((file) => (
<div
key={file.id}
className="text-xs text-muted-foreground truncate flex items-center gap-1.5"
className="text-xs sm:text-sm text-muted-foreground truncate flex items-center gap-1.5"
title={file.name}
>
{getFileIconFromName(file.name)}
@ -260,6 +199,7 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ 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}`}
>
<X className="size-3.5" />
</button>
@ -269,96 +209,23 @@ export const OneDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigCh
</div>
)}
{!browserOpen ? (
<Button
type="button"
variant="outline"
onClick={handleOpenBrowser}
disabled={isAuthExpired}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{totalSelected > 0 ? "Change Selection" : "Browse OneDrive"}
</Button>
) : (
<div className="rounded-lg border border-border bg-background">
{/* Breadcrumbs */}
<div className="flex items-center gap-1 px-3 py-2 border-b border-border text-xs overflow-x-auto">
{breadcrumbs.map((crumb, index) => (
<span key={crumb.id} className="flex items-center gap-1 shrink-0">
{index > 0 && <ChevronRight className="size-3 text-muted-foreground" />}
<button
type="button"
onClick={() => handleBreadcrumbClick(index)}
className={`hover:underline ${
index === breadcrumbs.length - 1
? "font-medium text-foreground"
: "text-muted-foreground"
}`}
>
{crumb.name}
</button>
</span>
))}
</div>
<Button
type="button"
variant="outline"
onClick={openPicker}
disabled={pickerLoading || isAuthExpired}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
>
{pickerLoading && <Spinner size="xs" className="mr-1.5" />}
{totalSelected > 0 ? "Change Selection" : "Select from OneDrive"}
</Button>
{/* File list */}
<div className="max-h-48 overflow-y-auto">
{browseLoading ? (
<div className="flex items-center justify-center p-6">
<Spinner size="sm" />
</div>
) : browseError ? (
<div className="p-3 text-xs text-destructive">{browseError}</div>
) : browseItems.length === 0 ? (
<div className="p-3 text-xs text-muted-foreground">This folder is empty</div>
) : (
browseItems.map((item) => (
<div
key={item.id}
className="flex items-center gap-2 px-3 py-1.5 hover:bg-muted/50 text-xs"
>
<Checkbox
checked={isItemSelected(item)}
onCheckedChange={() => handleToggleItem(item)}
className="size-3.5"
/>
{item.isFolder ? (
<button
type="button"
className="flex items-center gap-1.5 flex-1 min-w-0 text-left"
onClick={() => handleNavigateFolder(item.id, item.name)}
>
<FolderOpen className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.name}</span>
</button>
) : (
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{getFileIconFromName(item.name)}
<span className="truncate">{item.name}</span>
</div>
)}
</div>
))
)}
</div>
<div className="px-3 py-2 border-t border-border flex justify-end">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setBrowserOpen(false)}
className="text-xs h-7"
>
Done
</Button>
</div>
</div>
)}
{pickerError && !isAuthExpired && <p className="text-xs text-destructive">{pickerError}</p>}
{isAuthExpired && (
<p className="text-xs text-amber-600 dark:text-amber-500">
Your OneDrive authentication has expired. Please re-authenticate using the button below.
Your OneDrive authentication has expired. Please re-authenticate using the button
below.
</p>
)}
</div>

View file

@ -9,6 +9,7 @@ export const searchSourceConnectorTypeEnum = z.enum([
"BAIDU_SEARCH_API",
"SLACK_CONNECTOR",
"TEAMS_CONNECTOR",
"ONEDRIVE_CONNECTOR",
"NOTION_CONNECTOR",
"GITHUB_CONNECTOR",
"LINEAR_CONNECTOR",

View file

@ -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<string | null>(null);
const onPickedRef = useRef(onPicked);
onPickedRef.current = onPicked;
const openingRef = useRef(false);
const winRef = useRef<Window | null>(null);
const portRef = useRef<MessagePort | null>(null);
const messageHandlerRef = useRef<((e: MessageEvent) => void) | null>(null);
const pollRef = useRef<ReturnType<typeof setInterval> | 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<string, unknown>[] = 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 };
}