diff --git a/app/api/dropbox.py b/app/api/dropbox.py index f9bf10e5..3abe83a8 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -207,6 +207,91 @@ async def test_dropbox_token(request: Request): return {"status": "error", "message": f"Connection error: {str(e)}"} +@router.post("/dropbox/list-folders") +@require_login +async def list_dropbox_folders( + request: Request, + access_token: Annotated[str, Form(...)], + path: Annotated[str, Form()] = "", +): + """ + List folders in a Dropbox account for the directory selector. + + Accepts an OAuth access token (short-lived) and a path to list. + Returns a flat list of folder entries under the given path. + """ + try: + # Normalize path: Dropbox API uses "" for root, otherwise "/path" + folder_path = path.strip() + if folder_path == "/": + folder_path = "" + elif folder_path and not folder_path.startswith("/"): + folder_path = f"/{folder_path}" + + headers = { + "Authorization": f"Bearer {access_token}", + "Content-Type": "application/json", + } + + payload = { + "path": folder_path, + "recursive": False, + "include_deleted": False, + "include_has_explicit_shared_members": False, + "include_mounted_folders": True, + } + + response = requests.post( + "https://api.dropboxapi.com/2/files/list_folder", + headers=headers, + json=payload, + timeout=settings.http_request_timeout, + ) + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Access token is invalid or expired. Please re-authorize.", + ) + + if response.status_code != 200: + logger.error(f"Dropbox list_folder failed: {response.status_code} {response.text}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to list Dropbox folders: {response.text}", + ) + + data = response.json() + folders = [] + for entry in data.get("entries", []): + if entry.get(".tag") == "folder": + folders.append( + { + "name": entry["name"], + "path": entry["path_display"], + "id": entry.get("id", ""), + } + ) + + # Sort folders alphabetically + folders.sort(key=lambda f: f["name"].lower()) + + return { + "folders": folders, + "path": folder_path or "/", + "has_more": data.get("has_more", False), + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error listing Dropbox folders: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list folders: {str(e)}", + ) + + @router.post("/dropbox/save-settings") @require_login async def save_dropbox_settings( diff --git a/app/api/onedrive.py b/app/api/onedrive.py index e9f8328d..ca13c3bf 100644 --- a/app/api/onedrive.py +++ b/app/api/onedrive.py @@ -56,6 +56,7 @@ async def exchange_onedrive_token( # Return just what's needed by the frontend return { "refresh_token": token_data["refresh_token"], + "access_token": token_data.get("access_token", ""), "expires_in": token_data.get("expires_in", 3600), } @@ -206,6 +207,102 @@ async def test_onedrive_token(request: Request): return {"status": "error", "message": f"Connection error: {str(e)}"} +@router.post("/onedrive/list-folders") +@require_login +async def list_onedrive_folders( + request: Request, + access_token: Annotated[str, Form(...)], + path: Annotated[str, Form()] = "", +): + """ + List folders in a OneDrive account for the directory selector. + + Accepts an OAuth access token (short-lived) and a path to list. + Returns a flat list of folder entries under the given path. + """ + try: + folder_path = path.strip().strip("/") + + headers = { + "Authorization": f"Bearer {access_token}", + } + + # Build the Graph API URL for listing children + if not folder_path or folder_path == "root": + url = "https://graph.microsoft.com/v1.0/me/drive/root/children" + else: + url = f"https://graph.microsoft.com/v1.0/me/drive/root:/{folder_path}:/children" + + # Only request folders and minimal fields + params = { + "$filter": "folder ne null", + "$select": "name,id,parentReference,folder", + "$top": "200", + } + + response = requests.get( + url, + headers=headers, + params=params, + timeout=settings.http_request_timeout, + ) + + if response.status_code == 401: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Access token is invalid or expired. Please re-authorize.", + ) + + if response.status_code != 200: + logger.error(f"OneDrive list children failed: {response.status_code} {response.text}") + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, + detail=f"Failed to list OneDrive folders: {response.text}", + ) + + data = response.json() + folders = [] + for item in data.get("value", []): + if "folder" in item: + parent_path = "" + if item.get("parentReference", {}).get("path"): + # parentReference.path looks like /drive/root:/some/path + raw_parent = item["parentReference"]["path"] + prefix = "/drive/root:" + if raw_parent.startswith(prefix): + parent_path = raw_parent[len(prefix):] + elif raw_parent == "/drive/root": + parent_path = "" + + item_path = f"{parent_path}/{item['name']}" if parent_path else f"/{item['name']}" + + folders.append( + { + "name": item["name"], + "path": item_path, + "id": item.get("id", ""), + "child_count": item.get("folder", {}).get("childCount", 0), + } + ) + + # Sort folders alphabetically + folders.sort(key=lambda f: f["name"].lower()) + + return { + "folders": folders, + "path": f"/{folder_path}" if folder_path else "/", + } + + except HTTPException: + raise + except Exception as e: + logger.exception(f"Error listing OneDrive folders: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Failed to list folders: {str(e)}", + ) + + def format_time_remaining(time_delta): """Format a timedelta into a human-readable string.""" if time_delta.total_seconds() <= 0: diff --git a/frontend/templates/dropbox_callback.html b/frontend/templates/dropbox_callback.html index fd7b0f31..e59c6e40 100644 --- a/frontend/templates/dropbox_callback.html +++ b/frontend/templates/dropbox_callback.html @@ -59,6 +59,40 @@ + +
Browse your Dropbox to select a folder for this integration.
+ +Loading folders…
+@@ -173,18 +207,21 @@ document.addEventListener('DOMContentLoaded', function() { } return response.json(); }).then(() => { - // Clean up + // Clean up session storage sessionStorage.removeItem('dropbox_app_key'); sessionStorage.removeItem('dropbox_app_secret'); sessionStorage.removeItem('dropbox_folder_path'); sessionStorage.removeItem('oauth_integration_id'); + sessionStorage.removeItem('dropbox_use_system_creds'); - // Show brief success then redirect to integrations - document.getElementById('processing-message').innerHTML = - '
✓ Dropbox authorized successfully!
' + - 'Redirecting to Integrations...
'; + // Hide processing spinner, show success document.querySelector('.animate-spin').parentNode.classList.add('hidden'); - setTimeout(() => { window.location.href = '/integrations'; }, 2000); + document.getElementById('processing-message').innerHTML = + '✓ Dropbox authorized successfully!
'; + document.getElementById('success-container').classList.remove('hidden'); + + // Show folder browser with the access token + initFolderBrowser(data.access_token, integrationId); }); } @@ -279,6 +316,122 @@ DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`; }); } } + + // ── Folder browser ──────────────────────────────────────────────── + function initFolderBrowser(accessToken, integrationId) { + const folderSelector = document.getElementById('folder-selector'); + if (!folderSelector || !integrationId) return; + + folderSelector.classList.remove('hidden'); + let currentPath = ''; + + const folderList = document.getElementById('folder-list'); + const breadcrumb = document.getElementById('folder-breadcrumb'); + const selectedInput = document.getElementById('selected-folder-path'); + const saveBtn = document.getElementById('save-folder-btn'); + const saveStatus = document.getElementById('folder-save-status'); + + function loadFolders(path) { + currentPath = path; + folderList.innerHTML = 'Loading folders…
Browse your OneDrive to select a folder for this integration.
+ +Loading folders…
+@@ -177,19 +211,22 @@ document.addEventListener('DOMContentLoaded', function() { } return response.json(); }).then(() => { - // Clean up + // Clean up session storage sessionStorage.removeItem('onedrive_client_id'); sessionStorage.removeItem('onedrive_client_secret'); sessionStorage.removeItem('onedrive_tenant_id'); sessionStorage.removeItem('onedrive_folder_path'); sessionStorage.removeItem('oauth_integration_id'); + sessionStorage.removeItem('onedrive_use_system_creds'); - // Show brief success then redirect to integrations - document.getElementById('processing-message').innerHTML = - '
✓ OneDrive authorized successfully!
' + - 'Redirecting to Integrations...
'; + // Hide processing spinner, show success document.querySelector('.animate-spin').parentNode.classList.add('hidden'); - setTimeout(() => { window.location.href = '/integrations'; }, 2000); + document.getElementById('processing-message').innerHTML = + '✓ OneDrive authorized successfully!
'; + document.getElementById('success-container').classList.remove('hidden'); + + // Show folder browser with the access token + initFolderBrowser(data.access_token, integrationId); }); } @@ -291,6 +328,114 @@ ONEDRIVE_FOLDER_PATH=${folderPath || 'Documents/Uploads'}`; }); } } + + // ── Folder browser ──────────────────────────────────────────────── + function initFolderBrowser(accessToken, integrationId) { + const folderSelector = document.getElementById('folder-selector'); + if (!folderSelector || !integrationId || !accessToken) return; + + folderSelector.classList.remove('hidden'); + + const folderList = document.getElementById('folder-list'); + const breadcrumb = document.getElementById('folder-breadcrumb'); + const selectedInput = document.getElementById('selected-folder-path'); + const saveBtn = document.getElementById('save-folder-btn'); + const saveStatus = document.getElementById('folder-save-status'); + + function loadFolders(path) { + folderList.innerHTML = 'Loading folders…