diff --git a/app/api/dropbox.py b/app/api/dropbox.py index 73cddec4..86f72a25 100644 --- a/app/api/dropbox.py +++ b/app/api/dropbox.py @@ -298,6 +298,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 25429344..9e19c303 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), } @@ -183,6 +184,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/app/views/google_drive.py b/app/views/google_drive.py index f6bde9c0..ec70f92c 100644 --- a/app/views/google_drive.py +++ b/app/views/google_drive.py @@ -45,6 +45,9 @@ async def google_drive_setup_page( except (json.JSONDecodeError, TypeError): cfg = {} folder_id = cfg.get("folder_id", "") + # Provide system-wide OAuth credentials when available so users can + # authorize without registering their own Google Cloud app. + has_system_credentials = bool(settings.google_drive_client_id and settings.google_drive_client_secret) return templates.TemplateResponse( "google_drive.html", { @@ -58,10 +61,13 @@ async def google_drive_setup_page( "use_oauth": True, "oauth_configured": bool(integration.credentials), "sa_configured": False, - "client_id": False, - "client_id_value": "", - "client_secret": False, - "client_secret_value": "", + "has_system_credentials": has_system_credentials, + "client_id": bool(settings.google_drive_client_id) if has_system_credentials else False, + "client_id_value": (settings.google_drive_client_id or "" if has_system_credentials else ""), + "client_secret": bool(settings.google_drive_client_secret) if has_system_credentials else False, + "client_secret_value": ( + settings.google_drive_client_secret or "" if has_system_credentials else "" + ), "refresh_token": False, "refresh_token_value": "", "has_credentials_json": False, @@ -90,6 +96,7 @@ async def google_drive_setup_page( "use_oauth": use_oauth, "oauth_configured": oauth_configured, "sa_configured": sa_configured, + "has_system_credentials": bool(settings.google_drive_client_id and settings.google_drive_client_secret), "client_id": bool(settings.google_drive_client_id), "client_id_value": settings.google_drive_client_id or "", "client_secret": bool(settings.google_drive_client_secret), diff --git a/app/views/onedrive.py b/app/views/onedrive.py index 4b8b3763..d9fd5e6e 100644 --- a/app/views/onedrive.py +++ b/app/views/onedrive.py @@ -44,6 +44,9 @@ async def onedrive_setup_page( cfg = {} # Support both "folder_path" (WATCH_FOLDER / ONEDRIVE destination) folder_path = cfg.get("folder_path", cfg.get("folder", "")) + # Provide system-wide app credentials when available so users can + # authorize without registering their own Azure/OneDrive app. + has_system_credentials = bool(settings.onedrive_client_id and settings.onedrive_client_secret) return templates.TemplateResponse( "onedrive.html", { @@ -54,11 +57,12 @@ async def onedrive_setup_page( "integration_name": integration.name, "integration_type": integration.integration_type, "folder_path": folder_path, - "client_id": False, - "client_id_value": "", - "client_secret": False, - "client_secret_value": "", - "tenant_id": "common", + "has_system_credentials": has_system_credentials, + "client_id": bool(settings.onedrive_client_id) if has_system_credentials else False, + "client_id_value": settings.onedrive_client_id or "" if has_system_credentials else "", + "client_secret": bool(settings.onedrive_client_secret) if has_system_credentials else False, + "client_secret_value": (settings.onedrive_client_secret or "" if has_system_credentials else ""), + "tenant_id": settings.onedrive_tenant_id or "common", "refresh_token": False, "refresh_token_value": "", }, @@ -75,6 +79,7 @@ async def onedrive_setup_page( "request": request, "user_mode": False, "is_configured": is_configured, + "has_system_credentials": bool(settings.onedrive_client_id and settings.onedrive_client_secret), "client_id": bool(settings.onedrive_client_id), "client_id_value": settings.onedrive_client_id or "", "client_secret": bool(settings.onedrive_client_secret), diff --git a/docs/API.md b/docs/API.md index 775bd593..bd48ec75 100644 --- a/docs/API.md +++ b/docs/API.md @@ -1404,6 +1404,55 @@ Get the current user's integration quota usage. } ``` +## Cloud Provider Folder Browser + +Browse folders in connected cloud storage providers. These endpoints are used by the OAuth callback pages to let users select a target folder after authorization. + +### POST /api/dropbox/list-folders + +List folders in a Dropbox account. Requires a short-lived OAuth access token obtained during the authorization flow. + +**Request (form-data):** + +| Field | Type | Required | Description | +|----------------|--------|----------|--------------------------------------| +| `access_token` | string | Yes | Dropbox OAuth access token | +| `path` | string | No | Folder path to list (default: root) | + +**Response (200):** +```json +{ + "folders": [ + { "name": "Documents", "path": "/Documents", "id": "id:abc123" }, + { "name": "Photos", "path": "/Photos", "id": "id:def456" } + ], + "path": "/", + "has_more": false +} +``` + +### POST /api/onedrive/list-folders + +List folders in a OneDrive account. Requires a short-lived OAuth access token obtained during the authorization flow. + +**Request (form-data):** + +| Field | Type | Required | Description | +|----------------|--------|----------|--------------------------------------| +| `access_token` | string | Yes | Microsoft Graph access token | +| `path` | string | No | Folder path to list (default: root) | + +**Response (200):** +```json +{ + "folders": [ + { "name": "Documents", "path": "/Documents", "id": "abc123", "child_count": 5 }, + { "name": "Pictures", "path": "/Pictures", "id": "def456", "child_count": 12 } + ], + "path": "/" +} +``` + ## Webhooks Manage webhook configurations for notifying external systems when document events occur. All webhook endpoints require admin access. diff --git a/docs/DropboxSetup.md b/docs/DropboxSetup.md index b0e44852..39989735 100644 --- a/docs/DropboxSetup.md +++ b/docs/DropboxSetup.md @@ -28,9 +28,10 @@ End users authorize their own Dropbox integration from the **Integrations** dash 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a Dropbox destination integration (or a Watch Folder with `source_type = dropbox`). 3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Dropbox App Key and App Secret in the wizard (or use the global admin credentials if pre-configured). +4. If the administrator has configured system-wide Dropbox app credentials (`DROPBOX_APP_KEY` / `DROPBOX_APP_SECRET`), the wizard defaults to using them — no need to register your own Dropbox app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow**, authorize access in Dropbox, and the refresh token is automatically saved to your personal integration record. -6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. +6. After authorization, an interactive **folder browser** lets you select the target folder directly from your Dropbox — no need to manually type folder paths. +7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. > **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple Dropbox integrations with independent tokens. diff --git a/docs/GoogleDriveSetup.md b/docs/GoogleDriveSetup.md index e85addcc..bd0d59d5 100644 --- a/docs/GoogleDriveSetup.md +++ b/docs/GoogleDriveSetup.md @@ -30,7 +30,7 @@ End users can authorize their own Google Drive integration directly from the **I 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a Google Drive destination integration (or a Watch Folder with `source_type = google_drive`). 3. Click the **Authorize** button — it opens the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Google OAuth Client ID and Client Secret in the wizard. +4. If the administrator has configured system-wide Google Drive app credentials (`GOOGLE_DRIVE_CLIENT_ID` / `GOOGLE_DRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Google Cloud app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow** and authorize access in Google. 6. Credentials are saved automatically to your personal integration record; the page redirects back to `/integrations`. 7. Re-authorization is available at any time via the **Re-Authorize** button. diff --git a/docs/OneDriveSetup.md b/docs/OneDriveSetup.md index e9a555f4..ba2fc873 100644 --- a/docs/OneDriveSetup.md +++ b/docs/OneDriveSetup.md @@ -29,9 +29,10 @@ End users authorize their own OneDrive integration from the **Integrations** das 1. Navigate to `/integrations` and click **+ Add Destination** (or **+ Add Source** for Watch Folder). 2. Create a OneDrive destination integration (or a Watch Folder with `source_type = onedrive`). 3. Click the **Authorize** button next to the integration — it links directly to the OAuth wizard pre-loaded with your integration's configuration. -4. Enter your Azure AD Client ID and Client Secret in the wizard. +4. If the administrator has configured system-wide OneDrive app credentials (`ONEDRIVE_CLIENT_ID` / `ONEDRIVE_CLIENT_SECRET`), the wizard defaults to using them — no need to register your own Azure AD app. Uncheck the toggle to use custom credentials if needed. 5. Click **Start Authentication Flow**, authorize access via Microsoft, and the refresh token is automatically saved to your personal integration record. -6. The page redirects back to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. +6. After authorization, an interactive **folder browser** lets you select the target folder directly from your OneDrive — no need to manually type folder paths. +7. The page redirects to `/integrations` on success. Re-authorization is available at any time via the **Re-Authorize** button. > **Note:** Your credentials are stored encrypted per-integration and are never mixed with other users' data. Each user can have multiple OneDrive integrations with independent tokens. diff --git a/frontend/templates/dropbox_callback.html b/frontend/templates/dropbox_callback.html index 5b2efd3f..bedddc70 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…
+@@ -254,18 +288,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); }); } @@ -360,6 +397,128 @@ DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`; }); } } + + // ── Folder browser ──────────────────────────────────────────────── + function escapeHtml(str) { + const div = document.createElement('div'); + div.appendChild(document.createTextNode(str)); + return div.innerHTML; + } + + 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…
Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.