Merge pull request #586 from christianlouis/copilot/fix-watch-folder-settings
feat(integrations): simplify OAuth watch folder setup with system credentials and folder browser
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
+10
-5
@@ -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),
|
||||
|
||||
+49
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -59,6 +59,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder selector (shown after authorization) -->
|
||||
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
|
||||
<h3 class="font-medium text-lg mb-3">
|
||||
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
|
||||
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
|
||||
</svg>
|
||||
Select Folder
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Browse your Dropbox to select a folder for this integration.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
|
||||
<div class="p-4 text-center text-gray-500">
|
||||
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
|
||||
<p class="text-sm">Loading folders…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
|
||||
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
|
||||
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Save Folder
|
||||
</button>
|
||||
</div>
|
||||
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
||||
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
@@ -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 =
|
||||
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>' +
|
||||
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
|
||||
// Hide processing spinner, show success
|
||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>';
|
||||
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 = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
|
||||
|
||||
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 =>
|
||||
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
|
||||
`<svg class="h-5 w-5 text-yellow-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
|
||||
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
|
||||
`</button>`
|
||||
).join('');
|
||||
|
||||
folderList.querySelectorAll('.folder-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = btn.getAttribute('data-path');
|
||||
selectedInput.value = p;
|
||||
loadFolders(p);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
folderList.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
|
||||
}
|
||||
updateBreadcrumb(path);
|
||||
})
|
||||
.catch(err => {
|
||||
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateBreadcrumb(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
accumulated += '/' + part;
|
||||
html += `<span class="mx-1 text-gray-400">/</span>`;
|
||||
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated)}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
|
||||
}
|
||||
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('');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -167,14 +167,31 @@
|
||||
<p class="mb-4">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.</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value }}">
|
||||
{% if user_mode and has_system_credentials %}
|
||||
<!-- System credentials toggle (user mode only) -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
|
||||
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your Google account without needing your own Google Cloud app registration.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value }}">
|
||||
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -430,6 +447,9 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
|
||||
const systemClientId = {{ (client_id_value or '') | tojson }};
|
||||
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
|
||||
|
||||
// Store integration_id if provided (for per-user OAuth flow)
|
||||
const integrationId = "{{ integration_id or '' }}";
|
||||
@@ -437,6 +457,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
sessionStorage.setItem('oauth_integration_id', integrationId);
|
||||
}
|
||||
|
||||
// System credentials toggle
|
||||
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
|
||||
const customCredsSection = document.getElementById('custom-creds-section');
|
||||
if (useSystemCredsCheckbox && customCredsSection) {
|
||||
useSystemCredsCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
customCredsSection.classList.add('hidden');
|
||||
} else {
|
||||
customCredsSection.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form elements
|
||||
const clientIdInput = document.getElementById('client-id');
|
||||
const clientSecretInput = document.getElementById('client-secret');
|
||||
@@ -719,8 +752,10 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
|
||||
// Start OAuth flow button
|
||||
if (startOauthFlowBtn) {
|
||||
startOauthFlowBtn.addEventListener('click', function() {
|
||||
const clientId = clientIdInput.value.trim();
|
||||
const clientSecret = clientSecretInput.value.trim();
|
||||
// Determine which credentials to use
|
||||
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
|
||||
const clientId = useSystemCreds ? systemClientId : clientIdInput.value.trim();
|
||||
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
|
||||
const folderId = folderIdInput ? folderIdInput.value.trim() : '';
|
||||
|
||||
if (!clientId) {
|
||||
@@ -736,6 +771,9 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
|
||||
// Save values to session storage for use after redirect
|
||||
sessionStorage.setItem('google_drive_client_id', clientId);
|
||||
sessionStorage.setItem('google_drive_client_secret', clientSecret);
|
||||
if (useSystemCreds) {
|
||||
sessionStorage.setItem('google_drive_use_system_creds', 'true');
|
||||
}
|
||||
|
||||
// In admin mode store folder_id; in user mode the config is already set
|
||||
if (!userMode && folderId) {
|
||||
|
||||
@@ -130,20 +130,37 @@
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value }}">
|
||||
{% if user_mode and has_system_credentials %}
|
||||
<!-- System credentials toggle (user mode only) -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
|
||||
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your OneDrive account without needing your own Azure app registration.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value }}">
|
||||
</div>
|
||||
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
|
||||
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
|
||||
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
|
||||
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
|
||||
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not user_mode %}
|
||||
@@ -289,6 +306,10 @@ ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
|
||||
const systemClientId = {{ (client_id_value or '') | tojson }};
|
||||
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
|
||||
const systemTenantId = {{ (tenant_id or 'common') | tojson }};
|
||||
|
||||
// Store integration_id if provided (for per-user OAuth flow)
|
||||
const integrationId = "{{ integration_id or '' }}";
|
||||
@@ -302,6 +323,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
||||
const tokenStatus = document.getElementById('token-status');
|
||||
const clientSecretInput = document.getElementById('client-secret');
|
||||
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
|
||||
const customCredsSection = document.getElementById('custom-creds-section');
|
||||
|
||||
// Toggle custom credentials section visibility
|
||||
if (useSystemCredsCheckbox && customCredsSection) {
|
||||
useSystemCredsCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
customCredsSection.classList.add('hidden');
|
||||
} else {
|
||||
customCredsSection.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Modal elements
|
||||
const resultModal = document.getElementById('resultModal');
|
||||
@@ -351,10 +385,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Start Authentication Flow button click
|
||||
startAuthFlowBtn.addEventListener('click', function() {
|
||||
const clientId = document.getElementById('client-id').value.trim();
|
||||
const clientSecret = clientSecretInput.value.trim();
|
||||
// Determine which credentials to use
|
||||
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
|
||||
const clientId = useSystemCreds ? systemClientId : document.getElementById('client-id').value.trim();
|
||||
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
|
||||
const redirectUri = window.location.origin + "/onedrive-callback";
|
||||
const tenantId = document.getElementById('tenant-id').value.trim() || 'common';
|
||||
const tenantId = useSystemCreds ? systemTenantId : (document.getElementById('tenant-id').value.trim() || 'common');
|
||||
|
||||
if (!clientId) {
|
||||
showModal('error', 'Validation Error', 'Please enter your Client ID');
|
||||
@@ -370,6 +406,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
sessionStorage.setItem('onedrive_client_id', clientId);
|
||||
sessionStorage.setItem('onedrive_client_secret', clientSecret);
|
||||
sessionStorage.setItem('onedrive_tenant_id', tenantId);
|
||||
if (useSystemCreds) {
|
||||
sessionStorage.setItem('onedrive_use_system_creds', 'true');
|
||||
}
|
||||
|
||||
// In admin mode also store folder path; in user mode the config is already set
|
||||
if (!userMode) {
|
||||
|
||||
@@ -59,6 +59,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder selector (shown after authorization) -->
|
||||
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
|
||||
<h3 class="font-medium text-lg mb-3">
|
||||
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
|
||||
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
|
||||
</svg>
|
||||
Select Folder
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Browse your OneDrive to select a folder for this integration.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
|
||||
<div class="p-4 text-center text-gray-500">
|
||||
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
|
||||
<p class="text-sm">Loading folders…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
|
||||
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
|
||||
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Save Folder
|
||||
</button>
|
||||
</div>
|
||||
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
||||
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
@@ -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 =
|
||||
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>' +
|
||||
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
|
||||
// Hide processing spinner, show success
|
||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>';
|
||||
document.getElementById('success-container').classList.remove('hidden');
|
||||
|
||||
// Show folder browser with the access token
|
||||
initFolderBrowser(data.access_token, integrationId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,6 +328,120 @@ ONEDRIVE_FOLDER_PATH=${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 || !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 = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
|
||||
|
||||
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 =>
|
||||
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
|
||||
`<svg class="h-5 w-5 text-blue-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
|
||||
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
|
||||
`</button>`
|
||||
).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 = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
|
||||
}
|
||||
updateBreadcrumb(path);
|
||||
})
|
||||
.catch(err => {
|
||||
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateBreadcrumb(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
accumulated += '/' + part;
|
||||
html += `<span class="mx-1 text-gray-400">/</span>`;
|
||||
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated.replace(/^\//, ''))}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
|
||||
}
|
||||
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('');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -419,6 +419,149 @@ class TestSaveDropboxSettings:
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListDropboxFolders:
|
||||
"""Tests for list_dropbox_folders endpoint."""
|
||||
|
||||
@patch("app.api.dropbox.requests.post")
|
||||
def test_list_folders_success(self, mock_post, client):
|
||||
"""Test successful folder listing at root."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"entries": [
|
||||
{".tag": "folder", "name": "Documents", "path_display": "/Documents", "id": "id:1"},
|
||||
{".tag": "folder", "name": "Photos", "path_display": "/Photos", "id": "id:2"},
|
||||
{".tag": "file", "name": "readme.txt", "path_display": "/readme.txt", "id": "id:3"},
|
||||
],
|
||||
"has_more": False,
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/dropbox/list-folders",
|
||||
data={"access_token": "test-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["folders"]) == 2
|
||||
assert data["folders"][0]["name"] == "Documents"
|
||||
assert data["folders"][1]["name"] == "Photos"
|
||||
assert data["path"] == "/"
|
||||
assert data["has_more"] is False
|
||||
|
||||
@patch("app.api.dropbox.requests.post")
|
||||
def test_list_folders_subfolder(self, mock_post, client):
|
||||
"""Test listing folders in a subfolder."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"entries": [
|
||||
{".tag": "folder", "name": "Invoices", "path_display": "/Documents/Invoices", "id": "id:4"},
|
||||
],
|
||||
"has_more": False,
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/dropbox/list-folders",
|
||||
data={"access_token": "test-token", "path": "/Documents"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["folders"]) == 1
|
||||
assert data["folders"][0]["path"] == "/Documents/Invoices"
|
||||
|
||||
@patch("app.api.dropbox.requests.post")
|
||||
def test_list_folders_empty(self, mock_post, client):
|
||||
"""Test listing folders in an empty directory."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"entries": [], "has_more": False}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/dropbox/list-folders",
|
||||
data={"access_token": "test-token", "path": "/EmptyFolder"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["folders"]) == 0
|
||||
|
||||
@patch("app.api.dropbox.requests.post")
|
||||
def test_list_folders_unauthorized(self, mock_post, client):
|
||||
"""Test listing folders with invalid token returns 401."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
mock_response.text = "Invalid access token"
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/dropbox/list-folders",
|
||||
data={"access_token": "bad-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@patch("app.api.dropbox.requests.post")
|
||||
def test_list_folders_api_error(self, mock_post, client):
|
||||
"""Test listing folders when Dropbox API returns an error."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal server error"
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/dropbox/list-folders",
|
||||
data={"access_token": "test-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
|
||||
@patch("app.api.dropbox.requests.post")
|
||||
def test_list_folders_root_path_normalization(self, mock_post, client):
|
||||
"""Test that '/' is normalized to empty string for Dropbox API."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"entries": [], "has_more": False}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/dropbox/list-folders",
|
||||
data={"access_token": "test-token", "path": "/"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
# Check the actual API call used empty string for root
|
||||
call_args = mock_post.call_args
|
||||
assert call_args[1]["json"]["path"] == ""
|
||||
|
||||
@patch("app.api.dropbox.requests.post")
|
||||
def test_list_folders_sorted_alphabetically(self, mock_post, client):
|
||||
"""Test that folders are returned in alphabetical order."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"entries": [
|
||||
{".tag": "folder", "name": "Zebra", "path_display": "/Zebra", "id": "id:1"},
|
||||
{".tag": "folder", "name": "Alpha", "path_display": "/Alpha", "id": "id:2"},
|
||||
{".tag": "folder", "name": "middle", "path_display": "/middle", "id": "id:3"},
|
||||
],
|
||||
"has_more": False,
|
||||
}
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/dropbox/list-folders",
|
||||
data={"access_token": "test-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
names = [f["name"] for f in response.json()["folders"]]
|
||||
assert names == ["Alpha", "middle", "Zebra"]
|
||||
|
||||
|
||||
class TestBuildDropboxRedirectUri:
|
||||
"""Tests for the _build_dropbox_redirect_uri helper."""
|
||||
|
||||
|
||||
@@ -695,3 +695,182 @@ class TestOneDriveIntegration:
|
||||
|
||||
# Verify env format is present (exact values may vary)
|
||||
assert "env_format" in config_data
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestListOneDriveFolders:
|
||||
"""Tests for list_onedrive_folders endpoint."""
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
def test_list_folders_success(self, mock_get, client):
|
||||
"""Test successful folder listing at root."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"value": [
|
||||
{
|
||||
"name": "Documents",
|
||||
"id": "id:1",
|
||||
"folder": {"childCount": 3},
|
||||
"parentReference": {"path": "/drive/root:"},
|
||||
},
|
||||
{
|
||||
"name": "Pictures",
|
||||
"id": "id:2",
|
||||
"folder": {"childCount": 10},
|
||||
"parentReference": {"path": "/drive/root:"},
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/onedrive/list-folders",
|
||||
data={"access_token": "test-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["folders"]) == 2
|
||||
assert data["folders"][0]["name"] == "Documents"
|
||||
assert data["folders"][0]["path"] == "/Documents"
|
||||
assert data["folders"][1]["name"] == "Pictures"
|
||||
assert data["path"] == "/"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
def test_list_folders_subfolder(self, mock_get, client):
|
||||
"""Test listing folders in a subfolder."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"value": [
|
||||
{
|
||||
"name": "Invoices",
|
||||
"id": "id:3",
|
||||
"folder": {"childCount": 0},
|
||||
"parentReference": {"path": "/drive/root:/Documents"},
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/onedrive/list-folders",
|
||||
data={"access_token": "test-token", "path": "Documents"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert len(data["folders"]) == 1
|
||||
assert data["folders"][0]["path"] == "/Documents/Invoices"
|
||||
assert data["path"] == "/Documents"
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
def test_list_folders_empty(self, mock_get, client):
|
||||
"""Test listing folders in an empty directory."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"value": []}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/onedrive/list-folders",
|
||||
data={"access_token": "test-token", "path": "EmptyFolder"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert len(response.json()["folders"]) == 0
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
def test_list_folders_unauthorized(self, mock_get, client):
|
||||
"""Test listing folders with invalid token returns 401."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 401
|
||||
mock_response.text = "Invalid access token"
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/onedrive/list-folders",
|
||||
data={"access_token": "bad-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
def test_list_folders_api_error(self, mock_get, client):
|
||||
"""Test listing folders when Graph API returns an error."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal server error"
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/onedrive/list-folders",
|
||||
data={"access_token": "test-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 502
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
def test_list_folders_sorted_alphabetically(self, mock_get, client):
|
||||
"""Test that folders are returned in alphabetical order."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"value": [
|
||||
{
|
||||
"name": "Zebra",
|
||||
"id": "id:1",
|
||||
"folder": {"childCount": 0},
|
||||
"parentReference": {"path": "/drive/root:"},
|
||||
},
|
||||
{
|
||||
"name": "Alpha",
|
||||
"id": "id:2",
|
||||
"folder": {"childCount": 0},
|
||||
"parentReference": {"path": "/drive/root:"},
|
||||
},
|
||||
{
|
||||
"name": "middle",
|
||||
"id": "id:3",
|
||||
"folder": {"childCount": 0},
|
||||
"parentReference": {"path": "/drive/root:"},
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/onedrive/list-folders",
|
||||
data={"access_token": "test-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
names = [f["name"] for f in response.json()["folders"]]
|
||||
assert names == ["Alpha", "middle", "Zebra"]
|
||||
|
||||
@patch("app.api.onedrive.requests.get")
|
||||
def test_list_folders_root_drive_parent(self, mock_get, client):
|
||||
"""Test folder path construction when parentReference.path is /drive/root."""
|
||||
mock_response = Mock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {
|
||||
"value": [
|
||||
{
|
||||
"name": "TopLevel",
|
||||
"id": "id:1",
|
||||
"folder": {"childCount": 0},
|
||||
"parentReference": {"path": "/drive/root"},
|
||||
},
|
||||
],
|
||||
}
|
||||
mock_get.return_value = mock_response
|
||||
|
||||
response = client.post(
|
||||
"/api/onedrive/list-folders",
|
||||
data={"access_token": "test-token", "path": ""},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["folders"][0]["path"] == "/TopLevel"
|
||||
|
||||
Reference in New Issue
Block a user