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

Configuration for Worker Nodes

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

'; + + const formData = new FormData(); + formData.append('access_token', accessToken); + formData.append('path', path); + + fetch('/api/dropbox/list-folders', { method: 'POST', body: formData }) + .then(r => r.json()) + .then(data => { + if (data.folders && data.folders.length > 0) { + folderList.innerHTML = data.folders.map(f => + `` + ).join(''); + + folderList.querySelectorAll('.folder-item').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p; + loadFolders(p); + }); + }); + } else { + folderList.innerHTML = '
No subfolders found
'; + } + updateBreadcrumb(path); + }) + .catch(err => { + folderList.innerHTML = `
Failed to load folders: ${err.message}
`; + }); + } + + function updateBreadcrumb(path) { + const parts = path.split('/').filter(Boolean); + let html = ''; + let accumulated = ''; + for (const part of parts) { + accumulated += '/' + part; + html += `/`; + html += ``; + } + breadcrumb.innerHTML = html; + breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p || '/'; + loadFolders(p); + }); + }); + } + + // Save selected folder to integration config + saveBtn.addEventListener('click', () => { + const folderPath = selectedInput.value.trim() || '/'; + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + + // Get the current integration config, update folder_path, then PUT back + fetch(`/api/integrations/${integrationId}`) + .then(r => r.json()) + .then(intg => { + const cfg = intg.config || {}; + // Update the correct folder key based on integration type + if (cfg.source_type) { + cfg.folder_path = folderPath; + } else { + cfg.folder = folderPath; + } + return fetch(`/api/integrations/${integrationId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: cfg }), + }); + }) + .then(r => { + if (!r.ok) throw new Error('Failed to save folder'); + return r.json(); + }) + .then(() => { + saveStatus.textContent = '✓ Folder saved! Redirecting…'; + saveStatus.className = 'mt-2 text-sm text-green-600'; + saveStatus.classList.remove('hidden'); + saveBtn.textContent = 'Saved ✓'; + setTimeout(() => { window.location.href = '/integrations'; }, 1500); + }) + .catch(err => { + saveStatus.textContent = 'Error: ' + err.message; + saveStatus.className = 'mt-2 text-sm text-red-600'; + saveStatus.classList.remove('hidden'); + saveBtn.disabled = false; + saveBtn.textContent = 'Save Folder'; + }); + }); + + // Load root folders initially + loadFolders(''); + } }); {% endblock %} diff --git a/frontend/templates/onedrive_callback.html b/frontend/templates/onedrive_callback.html index 3eece4e9..306f0ec4 100644 --- a/frontend/templates/onedrive_callback.html +++ b/frontend/templates/onedrive_callback.html @@ -59,6 +59,40 @@
+ + +

Configuration for Worker Nodes

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

'; + + const formData = new FormData(); + formData.append('access_token', accessToken); + formData.append('path', path); + + fetch('/api/onedrive/list-folders', { method: 'POST', body: formData }) + .then(r => r.json()) + .then(data => { + if (data.folders && data.folders.length > 0) { + folderList.innerHTML = data.folders.map(f => + `` + ).join(''); + + folderList.querySelectorAll('.folder-item').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p; + loadFolders(p.replace(/^\//, '')); + }); + }); + } else { + folderList.innerHTML = '
No subfolders found
'; + } + updateBreadcrumb(path); + }) + .catch(err => { + folderList.innerHTML = `
Failed to load folders: ${err.message}
`; + }); + } + + function updateBreadcrumb(path) { + const parts = path.split('/').filter(Boolean); + let html = ''; + let accumulated = ''; + for (const part of parts) { + accumulated += '/' + part; + html += `/`; + html += ``; + } + breadcrumb.innerHTML = html; + breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => { + btn.addEventListener('click', () => { + const p = btn.getAttribute('data-path'); + selectedInput.value = p ? '/' + p : '/'; + loadFolders(p); + }); + }); + } + + // Save selected folder to integration config + saveBtn.addEventListener('click', () => { + const folderPath = selectedInput.value.trim() || '/'; + saveBtn.disabled = true; + saveBtn.textContent = 'Saving…'; + + fetch(`/api/integrations/${integrationId}`) + .then(r => r.json()) + .then(intg => { + const cfg = intg.config || {}; + cfg.folder_path = folderPath; + return fetch(`/api/integrations/${integrationId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ config: cfg }), + }); + }) + .then(r => { + if (!r.ok) throw new Error('Failed to save folder'); + return r.json(); + }) + .then(() => { + saveStatus.textContent = '✓ Folder saved! Redirecting…'; + saveStatus.className = 'mt-2 text-sm text-green-600'; + saveStatus.classList.remove('hidden'); + saveBtn.textContent = 'Saved ✓'; + setTimeout(() => { window.location.href = '/integrations'; }, 1500); + }) + .catch(err => { + saveStatus.textContent = 'Error: ' + err.message; + saveStatus.className = 'mt-2 text-sm text-red-600'; + saveStatus.classList.remove('hidden'); + saveBtn.disabled = false; + saveBtn.textContent = 'Save Folder'; + }); + }); + + // Load root folders initially + loadFolders(''); + } }); {% endblock %}