Merge pull request #770 from christianlouis/copilot/add-dropbox-test-integration
feat(integrations): Dropbox connection test + global credential sharing for integrations & social login
This commit is contained in:
@@ -23,6 +23,81 @@ logger = logging.getLogger(__name__)
|
|||||||
router = APIRouter()
|
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")
|
@router.post("/dropbox/exchange-token")
|
||||||
@require_login
|
@require_login
|
||||||
async def exchange_dropbox_token(
|
async def exchange_dropbox_token(
|
||||||
|
|||||||
@@ -32,6 +32,21 @@ from app.utils.encryption import decrypt_value, encrypt_value
|
|||||||
from app.utils.subscription import get_tier, get_user_tier_id
|
from app.utils.subscription import get_tier, get_user_tier_id
|
||||||
from app.utils.user_scope import get_current_owner_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__)
|
logger = logging.getLogger(__name__)
|
||||||
router = APIRouter(prefix="/integrations", tags=["integrations"])
|
router = APIRouter(prefix="/integrations", tags=["integrations"])
|
||||||
|
|
||||||
@@ -550,6 +565,47 @@ def _test_s3_connection(config: dict[str, Any] | None, credentials: dict[str, An
|
|||||||
return {"success": False, "message": "S3 connection failed"}
|
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]:
|
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."""
|
"""Test a WebDAV/Nextcloud connection by issuing an HTTP PROPFIND."""
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -596,6 +652,7 @@ def _test_webdav_connection(config: dict[str, Any] | None, credentials: dict[str
|
|||||||
|
|
||||||
|
|
||||||
_CONNECTION_TESTERS: dict[str, Any] = {
|
_CONNECTION_TESTERS: dict[str, Any] = {
|
||||||
|
IntegrationType.DROPBOX: _test_dropbox_connection,
|
||||||
IntegrationType.IMAP: _test_imap_connection,
|
IntegrationType.IMAP: _test_imap_connection,
|
||||||
IntegrationType.S3: _test_s3_connection,
|
IntegrationType.S3: _test_s3_connection,
|
||||||
IntegrationType.WEBDAV: _test_webdav_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")
|
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
||||||
|
|
||||||
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
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(
|
oauth.register(
|
||||||
name="dropbox",
|
name="dropbox",
|
||||||
client_id=settings.social_auth_dropbox_client_id,
|
client_id=_dropbox_client_id,
|
||||||
client_secret=settings.social_auth_dropbox_client_secret,
|
client_secret=_dropbox_client_secret,
|
||||||
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
authorize_url="https://www.dropbox.com/oauth2/authorize",
|
||||||
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
access_token_url="https://api.dropboxapi.com/oauth2/token",
|
||||||
userinfo_endpoint="https://api.dropboxapi.com/2/users/get_current_account",
|
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_app_secret: Optional[str] = None
|
||||||
dropbox_folder: Optional[str] = None
|
dropbox_folder: Optional[str] = None
|
||||||
dropbox_refresh_token: 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
|
# Making Nextcloud optional
|
||||||
nextcloud_enabled: bool = Field(
|
nextcloud_enabled: bool = Field(
|
||||||
@@ -317,6 +327,16 @@ class Settings(BaseSettings):
|
|||||||
social_auth_dropbox_enabled: bool = False
|
social_auth_dropbox_enabled: bool = False
|
||||||
social_auth_dropbox_client_id: Optional[str] = None
|
social_auth_dropbox_client_id: Optional[str] = None
|
||||||
social_auth_dropbox_client_secret: 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
|
# Local user signup
|
||||||
allow_local_signup: bool = Field(
|
allow_local_signup: bool = Field(
|
||||||
|
|||||||
@@ -370,6 +370,19 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"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": {
|
"social_auth_dropbox_enabled": {
|
||||||
"category": "Social Login",
|
"category": "Social Login",
|
||||||
"description": (
|
"description": (
|
||||||
@@ -763,6 +776,18 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_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
|
# Storage Providers - Nextcloud
|
||||||
"nextcloud_enabled": {
|
"nextcloud_enabled": {
|
||||||
"category": "Storage Providers",
|
"category": "Storage Providers",
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ async def dropbox_setup_page(
|
|||||||
cfg = {}
|
cfg = {}
|
||||||
# Support both "folder" (DROPBOX destination) and "folder_path" (WATCH_FOLDER source)
|
# Support both "folder" (DROPBOX destination) and "folder_path" (WATCH_FOLDER source)
|
||||||
folder_path = cfg.get("folder", cfg.get("folder_path", ""))
|
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(
|
return templates.TemplateResponse(
|
||||||
"dropbox.html",
|
"dropbox.html",
|
||||||
{
|
{
|
||||||
@@ -56,9 +62,11 @@ async def dropbox_setup_page(
|
|||||||
"integration_name": integration.name,
|
"integration_name": integration.name,
|
||||||
"integration_type": integration.integration_type,
|
"integration_type": integration.integration_type,
|
||||||
"folder_path": folder_path,
|
"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": "",
|
"app_secret_value": "",
|
||||||
"refresh_token_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"}
|
{"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/
|
### GET /api/integrations/quota/
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,34 @@
|
|||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<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>
|
<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>
|
<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 }}">
|
<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
|
Start Authentication Flow
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
<!-- Token validation and status (admin mode only) -->
|
<!-- Token validation and status (admin mode only) -->
|
||||||
{% if not user_mode %}
|
{% if not user_mode %}
|
||||||
@@ -268,6 +297,7 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
|
|||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
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)
|
// Store integration_id if provided (for per-user OAuth flow)
|
||||||
const integrationId = "{{ integration_id or '' }}";
|
const integrationId = "{{ integration_id or '' }}";
|
||||||
@@ -277,6 +307,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
|
|
||||||
// Elements
|
// Elements
|
||||||
const startAuthFlowBtn = document.getElementById('start-auth-flow');
|
const startAuthFlowBtn = document.getElementById('start-auth-flow');
|
||||||
|
const startAuthFlowGlobalBtn = document.getElementById('start-auth-flow-global');
|
||||||
const testTokenBtn = document.getElementById('test-token');
|
const testTokenBtn = document.getElementById('test-token');
|
||||||
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
||||||
const tokenStatus = document.getElementById('token-status');
|
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
|
// Start Authentication Flow button click
|
||||||
startAuthFlowBtn.addEventListener('click', function() {
|
if (startAuthFlowBtn) {
|
||||||
|
startAuthFlowBtn.addEventListener('click', function() {
|
||||||
const appKey = document.getElementById('app-key').value.trim();
|
const appKey = document.getElementById('app-key').value.trim();
|
||||||
const appSecret = appSecretInput.value.trim();
|
const appSecret = appSecretInput.value.trim();
|
||||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
const redirectUri = window.location.origin + "/dropbox-callback";
|
||||||
@@ -362,6 +420,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
// Redirect the user to the Dropbox login page
|
// Redirect the user to the Dropbox login page
|
||||||
window.location.href = authUrl;
|
window.location.href = authUrl;
|
||||||
});
|
});
|
||||||
|
} // end if (startAuthFlowBtn)
|
||||||
|
|
||||||
// Test Token button click (admin mode only)
|
// Test Token button click (admin mode only)
|
||||||
if (testTokenBtn) {
|
if (testTokenBtn) {
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const code = "{{ code }}";
|
const code = "{{ code }}";
|
||||||
|
|
||||||
// Get credentials from session storage (these take precedence over server-provided values)
|
// 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 appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}";
|
||||||
const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}";
|
const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}";
|
||||||
const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads';
|
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
|
// Automatically exchange the code for a refresh token
|
||||||
if (code) {
|
if (code) {
|
||||||
if (!appKey || !appSecret) {
|
if (useGlobalCreds) {
|
||||||
showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
|
// Global credentials: the server handles the exchange using the admin app secret
|
||||||
return;
|
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 {
|
} else {
|
||||||
showError("No authorization code was found in the URL");
|
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) {
|
function exchangeCode(code, appKey, appSecret, redirectUri) {
|
||||||
const formData = new FormData();
|
const formData = new FormData();
|
||||||
formData.append('client_id', appKey);
|
formData.append('client_id', appKey);
|
||||||
|
|||||||
@@ -1392,7 +1392,7 @@ function integrationsDashboard() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
integration_type: intg.integration_type,
|
integration_type: intg.integration_type,
|
||||||
config: intg.config,
|
config: intg.config,
|
||||||
credentials: creds,
|
credentials: creds.credentials,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
|
|||||||
@@ -887,9 +887,9 @@ class TestConnectionTestEndpoint:
|
|||||||
def test_test_unsupported_type(self, int_client):
|
def test_test_unsupported_type(self, int_client):
|
||||||
"""Unsupported integration types return a helpful non-error message."""
|
"""Unsupported integration types return a helpful non-error message."""
|
||||||
payload = {
|
payload = {
|
||||||
"integration_type": "DROPBOX",
|
"integration_type": "FTP",
|
||||||
"config": {},
|
"config": {},
|
||||||
"credentials": {"token": "abc"},
|
"credentials": {"username": "user", "password": "pass"},
|
||||||
}
|
}
|
||||||
resp = int_client.post("/api/integrations/test", json=payload)
|
resp = int_client.post("/api/integrations/test", json=payload)
|
||||||
assert resp.status_code == 200
|
assert resp.status_code == 200
|
||||||
@@ -897,6 +897,83 @@ class TestConnectionTestEndpoint:
|
|||||||
assert data["success"] is False
|
assert data["success"] is False
|
||||||
assert "not yet supported" in data["message"]
|
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):
|
def test_test_invalid_type_returns_400(self, int_client):
|
||||||
"""Invalid integration_type returns 400."""
|
"""Invalid integration_type returns 400."""
|
||||||
payload = {
|
payload = {
|
||||||
|
|||||||
Reference in New Issue
Block a user