feat(integrations): add Dropbox connection test and global-credential sharing
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
This commit is contained in:
@@ -23,6 +23,81 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/dropbox/global-authorize-url")
|
||||
@require_login
|
||||
async def dropbox_global_authorize_url(request: Request):
|
||||
"""Return the Dropbox OAuth authorization URL using the global app credentials.
|
||||
|
||||
This endpoint is used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS``
|
||||
is enabled so that users can authorize their personal Dropbox integration without
|
||||
needing to supply their own app key/secret. Only the public ``app_key`` is
|
||||
embedded in the URL; the ``app_secret`` is never sent to the browser.
|
||||
"""
|
||||
if not settings.dropbox_allow_global_credentials_for_integrations:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Global credentials for integrations are not enabled",
|
||||
)
|
||||
if not settings.dropbox_app_key or not settings.dropbox_app_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Global Dropbox credentials are not configured",
|
||||
)
|
||||
redirect_uri = str(request.base_url).rstrip("/") + "/dropbox-callback"
|
||||
authorize_url = (
|
||||
"https://www.dropbox.com/oauth2/authorize"
|
||||
f"?client_id={settings.dropbox_app_key}"
|
||||
"&response_type=code"
|
||||
"&token_access_type=offline"
|
||||
f"&redirect_uri={redirect_uri}"
|
||||
)
|
||||
return {"authorize_url": authorize_url}
|
||||
|
||||
|
||||
@router.post("/dropbox/exchange-token-global")
|
||||
@require_login
|
||||
async def exchange_dropbox_token_global(
|
||||
request: Request,
|
||||
code: Annotated[str, Form(...)],
|
||||
redirect_uri: Annotated[str, Form(...)],
|
||||
):
|
||||
"""Exchange an authorization code using the global Dropbox app credentials.
|
||||
|
||||
Used when ``DROPBOX_ALLOW_GLOBAL_CREDENTIALS_FOR_INTEGRATIONS`` is enabled so
|
||||
that the ``app_secret`` is never exposed to the browser. Only the OAuth code
|
||||
and redirect URI need to be supplied by the client.
|
||||
"""
|
||||
if not settings.dropbox_allow_global_credentials_for_integrations:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Global credentials for integrations are not enabled",
|
||||
)
|
||||
if not settings.dropbox_app_key or not settings.dropbox_app_secret:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="Global Dropbox credentials are not configured",
|
||||
)
|
||||
|
||||
token_url = "https://api.dropboxapi.com/oauth2/token"
|
||||
payload = {
|
||||
"client_id": settings.dropbox_app_key,
|
||||
"client_secret": settings.dropbox_app_secret,
|
||||
"code": code,
|
||||
"redirect_uri": redirect_uri,
|
||||
"grant_type": "authorization_code",
|
||||
}
|
||||
|
||||
token_data = exchange_oauth_token(provider_name="Dropbox", token_url=token_url, payload=payload)
|
||||
|
||||
return {
|
||||
"refresh_token": token_data["refresh_token"],
|
||||
"access_token": token_data["access_token"],
|
||||
"expires_in": token_data.get("expires_in", 14400),
|
||||
# Return the public app_key so the callback can store it in the integration
|
||||
"app_key": settings.dropbox_app_key,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/dropbox/exchange-token")
|
||||
@require_login
|
||||
async def exchange_dropbox_token(
|
||||
|
||||
@@ -32,6 +32,20 @@ from app.utils.encryption import decrypt_value, encrypt_value
|
||||
from app.utils.subscription import get_tier, get_user_tier_id
|
||||
from app.utils.user_scope import get_current_owner_id
|
||||
|
||||
# Optional Dropbox SDK — imported at module level so tests can patch it cleanly.
|
||||
try:
|
||||
import dropbox as dbx_lib
|
||||
from dropbox.exceptions import AuthError as _DropboxAuthError
|
||||
from dropbox.exceptions import BadInputError as _DropboxBadInputError
|
||||
except ImportError: # pragma: no cover
|
||||
dbx_lib = None # type: ignore[assignment]
|
||||
|
||||
class _DropboxAuthError(Exception): # type: ignore[no-redef]
|
||||
"""Stub — only used when the dropbox package is missing."""
|
||||
|
||||
class _DropboxBadInputError(Exception): # type: ignore[no-redef]
|
||||
"""Stub — only used when the dropbox package is missing."""
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(prefix="/integrations", tags=["integrations"])
|
||||
|
||||
@@ -550,6 +564,44 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An
|
||||
return {"success": False, "message": "S3 connection failed"}
|
||||
|
||||
|
||||
def _test_dropbox_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Test a Dropbox connection by verifying OAuth credentials via the Dropbox API."""
|
||||
if dbx_lib is None:
|
||||
return {"success": False, "message": "dropbox package is not installed"} # pragma: no cover
|
||||
|
||||
creds = credentials or {}
|
||||
app_key = creds.get("app_key", "")
|
||||
app_secret = creds.get("app_secret", "")
|
||||
refresh_token = creds.get("refresh_token", "")
|
||||
|
||||
if not refresh_token:
|
||||
return {"success": False, "message": "Missing required credential: refresh_token"}
|
||||
if not app_key or not app_secret:
|
||||
return {"success": False, "message": "Missing required credentials: app_key and app_secret"}
|
||||
|
||||
try:
|
||||
dbx = dbx_lib.Dropbox(
|
||||
app_key=app_key,
|
||||
app_secret=app_secret,
|
||||
oauth2_refresh_token=refresh_token,
|
||||
)
|
||||
account = dbx.users_get_current_account()
|
||||
display_name = getattr(account, "name", None)
|
||||
name_str = ""
|
||||
if display_name:
|
||||
name_str = f" ({getattr(display_name, 'display_name', '') or ''})"
|
||||
return {"success": True, "message": f"Dropbox connection successful{name_str}"}
|
||||
except _DropboxAuthError as exc:
|
||||
logger.warning("Dropbox auth error: %s", exc)
|
||||
return {"success": False, "message": "Dropbox authentication failed — check app_key, app_secret, and refresh_token"}
|
||||
except _DropboxBadInputError as exc:
|
||||
logger.warning("Dropbox bad input error: %s", exc)
|
||||
return {"success": False, "message": "Dropbox connection failed — invalid credentials format"}
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("Dropbox connection error: %s", exc)
|
||||
return {"success": False, "message": "Dropbox connection failed — check credentials and network connectivity"}
|
||||
|
||||
|
||||
def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str, Any] | None) -> dict[str, Any]:
|
||||
"""Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND."""
|
||||
import urllib.request
|
||||
@@ -596,6 +648,7 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
|
||||
|
||||
|
||||
_CONNECTION_TESTERS: dict[str, Any] = {
|
||||
IntegrationType.DROPBOX: _test_dropbox_connection,
|
||||
IntegrationType.IMAP: _test_imap_connection,
|
||||
IntegrationType.S3: _test_s3_connection,
|
||||
IntegrationType.WEBDAV: _test_webdav_connection,
|
||||
|
||||
+10
-3
@@ -103,11 +103,18 @@ if AUTH_ENABLED and settings.social_auth_apple_enabled:
|
||||
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
||||
if settings.social_auth_dropbox_client_id and settings.social_auth_dropbox_client_secret:
|
||||
# Determine which credentials to use for Dropbox social login
|
||||
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
||||
_dropbox_client_secret = settings.social_auth_dropbox_client_secret
|
||||
if settings.social_auth_dropbox_use_global_credentials and not _dropbox_client_id:
|
||||
_dropbox_client_id = settings.dropbox_app_key
|
||||
_dropbox_client_secret = settings.dropbox_app_secret
|
||||
|
||||
if _dropbox_client_id and _dropbox_client_secret:
|
||||
oauth.register(
|
||||
name="dropbox",
|
||||
client_id=settings.social_auth_dropbox_client_id,
|
||||
client_secret=settings.social_auth_dropbox_client_secret,
|
||||
client_id=_dropbox_client_id,
|
||||
client_secret=_dropbox_client_secret,
|
||||
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
||||
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
||||
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
||||
|
||||
@@ -120,6 +120,16 @@ class Settings(BaseSettings):
|
||||
dropbox_app_secret: Optional[str] = None
|
||||
dropbox_folder: Optional[str] = None
|
||||
dropbox_refresh_token: Optional[str] = None
|
||||
dropbox_allow_global_credentials_for_integrations: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When True, users may authorize their personal Dropbox integrations using the global "
|
||||
"DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without "
|
||||
"needing to create their own Dropbox app. The Dropbox OAuth flow is initiated "
|
||||
"server-side so the app secret is never exposed to the browser. "
|
||||
"Default: False (each user must supply their own app credentials)."
|
||||
),
|
||||
)
|
||||
|
||||
# Making Nextcloud optional
|
||||
nextcloud_enabled: bool = Field(
|
||||
@@ -317,6 +327,16 @@ class Settings(BaseSettings):
|
||||
social_auth_dropbox_enabled: bool = False
|
||||
social_auth_dropbox_client_id: Optional[str] = None
|
||||
social_auth_dropbox_client_secret: Optional[str] = None
|
||||
social_auth_dropbox_use_global_credentials: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET "
|
||||
"credentials (the storage integration credentials) instead of requiring separate "
|
||||
"SOCIAL_AUTH_DROPBOX_CLIENT_ID / SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. "
|
||||
"Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and the global Dropbox app credentials to be set. "
|
||||
"Default: False."
|
||||
),
|
||||
)
|
||||
|
||||
# Local user signup
|
||||
allow_local_signup: bool = Field(
|
||||
|
||||
@@ -370,6 +370,19 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_dropbox_use_global_credentials": {
|
||||
"category": "Social Login",
|
||||
"description": (
|
||||
"When True, Dropbox social login uses the global DROPBOX_APP_KEY / DROPBOX_APP_SECRET "
|
||||
"credentials instead of requiring separate SOCIAL_AUTH_DROPBOX_CLIENT_ID / "
|
||||
"SOCIAL_AUTH_DROPBOX_CLIENT_SECRET values. "
|
||||
"Requires SOCIAL_AUTH_DROPBOX_ENABLED=True and global Dropbox credentials to be set."
|
||||
),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_dropbox_enabled": {
|
||||
"category": "Social Login",
|
||||
"description": (
|
||||
@@ -763,6 +776,18 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"dropbox_allow_global_credentials_for_integrations": {
|
||||
"category": "Storage Providers",
|
||||
"description": (
|
||||
"When True, users may authorize their personal Dropbox integrations using the global "
|
||||
"DROPBOX_APP_KEY / DROPBOX_APP_SECRET credentials configured by the admin, without "
|
||||
"needing to create their own Dropbox app."
|
||||
),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Storage Providers - Nextcloud
|
||||
"nextcloud_enabled": {
|
||||
"category": "Storage Providers",
|
||||
|
||||
@@ -46,6 +46,12 @@ async def dropbox_setup_page(
|
||||
cfg = {}
|
||||
# Support both "folder" (DROPBOX destination) and "folder_path" (WATCH_FOLDER source)
|
||||
folder_path = cfg.get("folder", cfg.get("folder_path", ""))
|
||||
# Determine if global credentials are available for users to reuse
|
||||
global_creds_available = bool(
|
||||
settings.dropbox_allow_global_credentials_for_integrations
|
||||
and settings.dropbox_app_key
|
||||
and settings.dropbox_app_secret
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
"dropbox.html",
|
||||
{
|
||||
@@ -56,9 +62,11 @@ async def dropbox_setup_page(
|
||||
"integration_name": integration.name,
|
||||
"integration_type": integration.integration_type,
|
||||
"folder_path": folder_path,
|
||||
"app_key_value": "",
|
||||
# Only expose the public app key (not the secret) when global creds are allowed
|
||||
"app_key_value": settings.dropbox_app_key if global_creds_available else "",
|
||||
"app_secret_value": "",
|
||||
"refresh_token_value": "",
|
||||
"global_creds_available": global_creds_available,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
+1
-1
@@ -1379,7 +1379,7 @@ Test an integration connection without saving. Useful for "Test connection" UI b
|
||||
{"success": true, "message": "IMAP connection successful"}
|
||||
```
|
||||
|
||||
Supported connection tests: `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported.
|
||||
Supported connection tests: `DROPBOX`, `IMAP`, `S3`, `WEBDAV`, `NEXTCLOUD`. Other types return a message that testing is not yet supported.
|
||||
|
||||
### GET /api/integrations/quota/
|
||||
|
||||
|
||||
@@ -116,6 +116,34 @@
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% if user_mode and global_creds_available %}
|
||||
<!-- Global credentials mode: no app credentials required from the user -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4" role="note">
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 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="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">Using shared application credentials</p>
|
||||
<p class="text-sm text-blue-700 mt-1">Your administrator has enabled shared Dropbox app credentials. You can authorize your account without supplying your own App Key and Secret.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if folder_path %}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||||
<p class="text-xs text-gray-500">Target folder (from integration settings)</p>
|
||||
<p class="text-sm font-mono text-gray-700">{{ folder_path }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<button id="start-auth-flow-global" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>
|
||||
Authorize with Dropbox
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<label for="app-key" class="block text-sm font-medium text-gray-700">App Key <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="app-key" 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 Dropbox app key" value="{{ app_key_value }}">
|
||||
@@ -146,6 +174,7 @@
|
||||
Start Authentication Flow
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Token validation and status (admin mode only) -->
|
||||
{% if not user_mode %}
|
||||
@@ -268,6 +297,7 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||
const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }};
|
||||
|
||||
// Store integration_id if provided (for per-user OAuth flow)
|
||||
const integrationId = "{{ integration_id or '' }}";
|
||||
@@ -277,6 +307,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Elements
|
||||
const startAuthFlowBtn = document.getElementById('start-auth-flow');
|
||||
const startAuthFlowGlobalBtn = document.getElementById('start-auth-flow-global');
|
||||
const testTokenBtn = document.getElementById('test-token');
|
||||
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
||||
const tokenStatus = document.getElementById('token-status');
|
||||
@@ -328,8 +359,35 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Global-credentials "Authorize with Dropbox" button (user mode, admin-provided creds)
|
||||
if (startAuthFlowGlobalBtn) {
|
||||
startAuthFlowGlobalBtn.addEventListener('click', async function() {
|
||||
startAuthFlowGlobalBtn.disabled = true;
|
||||
startAuthFlowGlobalBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Redirecting…';
|
||||
try {
|
||||
const resp = await fetch('/api/dropbox/global-authorize-url');
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showModal('error', 'Error', err.detail || 'Could not retrieve authorization URL.');
|
||||
startAuthFlowGlobalBtn.disabled = false;
|
||||
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Signal to the callback that global credentials should be used for the exchange
|
||||
sessionStorage.setItem('dropbox_use_global_creds', 'true');
|
||||
window.location.href = data.authorize_url;
|
||||
} catch (err) {
|
||||
showModal('error', 'Network Error', err.message || 'Unknown error');
|
||||
startAuthFlowGlobalBtn.disabled = false;
|
||||
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start Authentication Flow button click
|
||||
startAuthFlowBtn.addEventListener('click', function() {
|
||||
if (startAuthFlowBtn) {
|
||||
startAuthFlowBtn.addEventListener('click', function() {
|
||||
const appKey = document.getElementById('app-key').value.trim();
|
||||
const appSecret = appSecretInput.value.trim();
|
||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
||||
@@ -362,6 +420,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect the user to the Dropbox login page
|
||||
window.location.href = authUrl;
|
||||
});
|
||||
} // end if (startAuthFlowBtn)
|
||||
|
||||
// Test Token button click (admin mode only)
|
||||
if (testTokenBtn) {
|
||||
|
||||
@@ -94,6 +94,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const code = "{{ code }}";
|
||||
|
||||
// Get credentials from session storage (these take precedence over server-provided values)
|
||||
const useGlobalCreds = sessionStorage.getItem('dropbox_use_global_creds') === 'true';
|
||||
const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}";
|
||||
const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}";
|
||||
const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads';
|
||||
@@ -111,16 +112,95 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Automatically exchange the code for a refresh token
|
||||
if (code) {
|
||||
if (!appKey || !appSecret) {
|
||||
showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
|
||||
return;
|
||||
if (useGlobalCreds) {
|
||||
// Global credentials: the server handles the exchange using the admin app secret
|
||||
exchangeCodeGlobal(code, redirectUri);
|
||||
} else {
|
||||
if (!appKey || !appSecret) {
|
||||
showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
|
||||
return;
|
||||
}
|
||||
exchangeCode(code, appKey, appSecret, redirectUri);
|
||||
}
|
||||
|
||||
exchangeCode(code, appKey, appSecret, redirectUri);
|
||||
} else {
|
||||
showError("No authorization code was found in the URL");
|
||||
}
|
||||
|
||||
function exchangeCodeGlobal(code, redirectUri) {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('redirect_uri', redirectUri);
|
||||
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p>Exchanging authorization code using shared credentials…</p>';
|
||||
|
||||
fetch('/api/dropbox/exchange-token-global', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to exchange token');
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.refresh_token) {
|
||||
const resolvedAppKey = data.app_key || '';
|
||||
if (integrationId) {
|
||||
const creds = {
|
||||
refresh_token: data.refresh_token,
|
||||
// Store public app_key with the integration (no secret stored browser-side)
|
||||
app_key: resolvedAppKey,
|
||||
// Flag so the backend knows to use global app_secret for future operations
|
||||
use_global_app_secret: true,
|
||||
};
|
||||
|
||||
const body = { credentials: creds };
|
||||
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p>Saving credentials to your integration…</p>';
|
||||
|
||||
return fetch(`/api/integrations/${integrationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error'));
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
}).then(() => {
|
||||
// Clean up
|
||||
sessionStorage.removeItem('dropbox_use_global_creds');
|
||||
sessionStorage.removeItem('oauth_integration_id');
|
||||
|
||||
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>';
|
||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
||||
});
|
||||
}
|
||||
// Global admin flow — the exchange-token-global endpoint is intended for
|
||||
// per-user integrations, so reaching here without an integrationId is unexpected.
|
||||
console.warn('dropbox_callback: global creds flow reached without integration_id');
|
||||
sessionStorage.removeItem('dropbox_use_global_creds');
|
||||
showSuccess(data.refresh_token, resolvedAppKey, '', folderPath);
|
||||
setTimeout(() => { window.location.href = '/status'; }, 10000);
|
||||
} else {
|
||||
throw new Error('No refresh token was received from the server');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function exchangeCode(code, appKey, appSecret, redirectUri) {
|
||||
const formData = new FormData();
|
||||
formData.append('client_id', appKey);
|
||||
|
||||
@@ -1392,7 +1392,7 @@ function integrationsDashboard() {
|
||||
body: JSON.stringify({
|
||||
integration_type: intg.integration_type,
|
||||
config: intg.config,
|
||||
credentials: creds,
|
||||
credentials: creds.credentials,
|
||||
}),
|
||||
});
|
||||
const data = await resp.json();
|
||||
|
||||
@@ -887,9 +887,9 @@ class TestConnectionTestEndpoint:
|
||||
def test_test_unsupported_type(self, int_client):
|
||||
"""Unsupported integration types return a helpful non-error message."""
|
||||
payload = {
|
||||
"integration_type": "DROPBOX",
|
||||
"integration_type": "FTP",
|
||||
"config": {},
|
||||
"credentials": {"token": "abc"},
|
||||
"credentials": {"username": "user", "password": "pass"},
|
||||
}
|
||||
resp = int_client.post("/api/integrations/test", json=payload)
|
||||
assert resp.status_code == 200
|
||||
@@ -897,6 +897,85 @@ class TestConnectionTestEndpoint:
|
||||
assert data["success"] is False
|
||||
assert "not yet supported" in data["message"]
|
||||
|
||||
def test_test_dropbox_missing_refresh_token(self, int_client):
|
||||
"""Dropbox test with missing refresh_token returns failure."""
|
||||
payload = {
|
||||
"integration_type": "DROPBOX",
|
||||
"config": {},
|
||||
"credentials": {"app_key": "key", "app_secret": "secret"},
|
||||
}
|
||||
resp = int_client.post("/api/integrations/test", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert "refresh_token" in data["message"].lower()
|
||||
|
||||
def test_test_dropbox_missing_app_key(self, int_client):
|
||||
"""Dropbox test with missing app_key/app_secret returns failure."""
|
||||
payload = {
|
||||
"integration_type": "DROPBOX",
|
||||
"config": {},
|
||||
"credentials": {"refresh_token": "rtoken"},
|
||||
}
|
||||
resp = int_client.post("/api/integrations/test", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert "app_key" in data["message"].lower()
|
||||
|
||||
def test_test_dropbox_invalid_credentials(self, int_client):
|
||||
"""Dropbox test with bad credentials returns an auth failure."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import dropbox.exceptions as dbx_exc
|
||||
|
||||
with patch("app.api.integrations.dbx_lib") as mock_dbx:
|
||||
mock_instance = MagicMock()
|
||||
mock_dbx.Dropbox.return_value = mock_instance
|
||||
mock_instance.users_get_current_account.side_effect = dbx_exc.AuthError(
|
||||
"req_id", MagicMock()
|
||||
)
|
||||
payload = {
|
||||
"integration_type": "DROPBOX",
|
||||
"config": {},
|
||||
"credentials": {
|
||||
"app_key": "bad_key",
|
||||
"app_secret": "bad_secret",
|
||||
"refresh_token": "bad_token",
|
||||
},
|
||||
}
|
||||
resp = int_client.post("/api/integrations/test", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is False
|
||||
assert "authentication failed" in data["message"].lower()
|
||||
|
||||
def test_test_dropbox_success(self, int_client):
|
||||
"""Dropbox test with valid (mocked) credentials returns success."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
with patch("app.api.integrations.dbx_lib") as mock_dbx:
|
||||
mock_instance = MagicMock()
|
||||
mock_dbx.Dropbox.return_value = mock_instance
|
||||
mock_account = MagicMock()
|
||||
mock_account.name.display_name = "Test User"
|
||||
mock_instance.users_get_current_account.return_value = mock_account
|
||||
|
||||
payload = {
|
||||
"integration_type": "DROPBOX",
|
||||
"config": {},
|
||||
"credentials": {
|
||||
"app_key": "valid_key",
|
||||
"app_secret": "valid_secret",
|
||||
"refresh_token": "valid_token",
|
||||
},
|
||||
}
|
||||
resp = int_client.post("/api/integrations/test", json=payload)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["success"] is True
|
||||
assert "dropbox connection successful" in data["message"].lower()
|
||||
|
||||
def test_test_invalid_type_returns_400(self, int_client):
|
||||
"""Invalid integration_type returns 400."""
|
||||
payload = {
|
||||
|
||||
Reference in New Issue
Block a user