Merge pull request #791 from christianlouis/copilot/build-diversified-login-config
feat(auth): add connections admin page, GitHub/Keycloak/Generic OAuth2 providers, fix Dropbox social login
This commit is contained in:
+114
-4
@@ -102,6 +102,33 @@ if AUTH_ENABLED and settings.social_auth_apple_enabled:
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_APPLE_ENABLED=true but client ID/team ID not configured")
|
||||
|
||||
|
||||
def _dropbox_userinfo_compliance_fix(client, user_cls, token, data):
|
||||
"""Normalize Dropbox userinfo response for authlib compatibility.
|
||||
|
||||
Dropbox's /2/users/get_current_account returns a non-standard response
|
||||
format. This compliance fix normalizes the response data — the HTTP
|
||||
method (POST) is handled by authlib's compliance infrastructure.
|
||||
|
||||
Args:
|
||||
client: The OAuth client instance (required by authlib compliance fix interface).
|
||||
user_cls: The user class (required by authlib compliance fix interface).
|
||||
token: The OAuth token dict.
|
||||
data: The raw userinfo response dict from Dropbox.
|
||||
|
||||
Returns:
|
||||
The normalized userinfo dict with ``sub`` and ``name`` fields.
|
||||
"""
|
||||
# Dropbox returns account_id instead of sub
|
||||
if "account_id" in data and "sub" not in data:
|
||||
data["sub"] = data["account_id"]
|
||||
# Normalize name field
|
||||
name_info = data.get("name", {})
|
||||
if isinstance(name_info, dict) and "display_name" in name_info:
|
||||
data["name"] = name_info["display_name"]
|
||||
return data
|
||||
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
||||
# Determine which credentials to use for Dropbox social login
|
||||
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
||||
@@ -118,13 +145,77 @@ if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
||||
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",
|
||||
client_kwargs={"token_endpoint_auth_method": "client_secret_post"},
|
||||
userinfo_compliance_fix=_dropbox_userinfo_compliance_fix,
|
||||
client_kwargs={
|
||||
"token_endpoint_auth_method": "client_secret_post",
|
||||
"token_access_type": "offline",
|
||||
},
|
||||
)
|
||||
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
||||
logger.info("Social login provider registered: Dropbox")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_github_enabled:
|
||||
if settings.social_auth_github_client_id and settings.social_auth_github_client_secret:
|
||||
oauth.register(
|
||||
name="github",
|
||||
client_id=settings.social_auth_github_client_id,
|
||||
client_secret=settings.social_auth_github_client_secret,
|
||||
authorize_url="https://github.com/login/oauth/authorize",
|
||||
access_token_url="https://github.com/login/oauth/access_token",
|
||||
userinfo_endpoint="https://api.github.com/user",
|
||||
client_kwargs={"scope": "read:user user:email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["github"] = {"name": "GitHub", "icon": "fab fa-github", "color": "gray"}
|
||||
logger.info("Social login provider registered: GitHub")
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GITHUB_ENABLED=true but client ID/secret not configured")
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_keycloak_enabled:
|
||||
_kc_server = settings.social_auth_keycloak_server_url
|
||||
_kc_realm = settings.social_auth_keycloak_realm
|
||||
if (
|
||||
settings.social_auth_keycloak_client_id
|
||||
and settings.social_auth_keycloak_client_secret
|
||||
and _kc_server
|
||||
and _kc_realm
|
||||
):
|
||||
_kc_base = f"{_kc_server.rstrip('/')}/realms/{_kc_realm}"
|
||||
oauth.register(
|
||||
name="keycloak",
|
||||
client_id=settings.social_auth_keycloak_client_id,
|
||||
client_secret=settings.social_auth_keycloak_client_secret,
|
||||
server_metadata_url=f"{_kc_base}/.well-known/openid-configuration",
|
||||
client_kwargs={"scope": "openid profile email"},
|
||||
)
|
||||
SOCIAL_PROVIDERS["keycloak"] = {"name": "Keycloak", "icon": "fas fa-key", "color": "gray"}
|
||||
logger.info("Social login provider registered: Keycloak (realm=%s)", _kc_realm)
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_KEYCLOAK_ENABLED=true but required settings not configured")
|
||||
|
||||
if AUTH_ENABLED and settings.social_auth_generic_oauth2_enabled:
|
||||
if (
|
||||
settings.social_auth_generic_oauth2_client_id
|
||||
and settings.social_auth_generic_oauth2_client_secret
|
||||
and settings.social_auth_generic_oauth2_authorize_url
|
||||
and settings.social_auth_generic_oauth2_token_url
|
||||
):
|
||||
oauth.register(
|
||||
name="generic_oauth2",
|
||||
client_id=settings.social_auth_generic_oauth2_client_id,
|
||||
client_secret=settings.social_auth_generic_oauth2_client_secret,
|
||||
authorize_url=settings.social_auth_generic_oauth2_authorize_url,
|
||||
access_token_url=settings.social_auth_generic_oauth2_token_url,
|
||||
userinfo_endpoint=settings.social_auth_generic_oauth2_userinfo_url,
|
||||
client_kwargs={"scope": settings.social_auth_generic_oauth2_scope},
|
||||
)
|
||||
_generic_name = settings.social_auth_generic_oauth2_name or "OAuth2"
|
||||
SOCIAL_PROVIDERS["generic_oauth2"] = {"name": _generic_name, "icon": "fas fa-sign-in-alt", "color": "indigo"}
|
||||
logger.info("Social login provider registered: Generic OAuth2 (%s)", _generic_name)
|
||||
else:
|
||||
logger.warning("SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true but required settings not configured")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@@ -348,13 +439,21 @@ async def login(request: Request):
|
||||
get_client_ip(request),
|
||||
)
|
||||
|
||||
error = request.query_params.get("error")
|
||||
message = request.query_params.get("message")
|
||||
show_oauth = OAUTH_CONFIGURED
|
||||
|
||||
# SSO Auto Login: redirect directly to SSO provider if configured
|
||||
if show_oauth and settings.sso_auto_login is True and not error and not message:
|
||||
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"login.html",
|
||||
{
|
||||
"request": request,
|
||||
"error": request.query_params.get("error"),
|
||||
"message": request.query_params.get("message"),
|
||||
"show_oauth": OAUTH_CONFIGURED,
|
||||
"error": error,
|
||||
"message": message,
|
||||
"show_oauth": show_oauth,
|
||||
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
||||
"social_providers": SOCIAL_PROVIDERS,
|
||||
"app_version": settings.version,
|
||||
@@ -440,6 +539,17 @@ def _normalize_social_userinfo(provider: str, token: dict, raw_userinfo: dict |
|
||||
"picture": userinfo.get("profile_photo_url", ""),
|
||||
}
|
||||
|
||||
if provider == "github":
|
||||
# GitHub returns login, id, name, email, avatar_url
|
||||
email = userinfo.get("email", "")
|
||||
return {
|
||||
"sub": str(userinfo.get("id", "")),
|
||||
"email": email,
|
||||
"name": userinfo.get("name", "") or userinfo.get("login", ""),
|
||||
"preferred_username": userinfo.get("login", email),
|
||||
"picture": userinfo.get("avatar_url", ""),
|
||||
}
|
||||
|
||||
# Standard OIDC providers (Google, Microsoft, Apple)
|
||||
return {
|
||||
"sub": userinfo.get("sub", ""),
|
||||
|
||||
@@ -305,6 +305,38 @@ class Settings(BaseSettings):
|
||||
authentik_client_secret: Optional[str] = None
|
||||
authentik_config_url: Optional[str] = None
|
||||
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
|
||||
sso_auto_login: bool = Field(
|
||||
default=False,
|
||||
description=(
|
||||
"Automatically redirect to SSO login when authentication is required. "
|
||||
"When enabled, users are sent directly to the SSO provider instead of "
|
||||
"seeing the login page. Only effective when OIDC is configured."
|
||||
),
|
||||
)
|
||||
|
||||
# Keycloak SSO
|
||||
social_auth_keycloak_enabled: bool = False
|
||||
social_auth_keycloak_client_id: Optional[str] = None
|
||||
social_auth_keycloak_client_secret: Optional[str] = None
|
||||
social_auth_keycloak_server_url: Optional[str] = None
|
||||
social_auth_keycloak_realm: Optional[str] = None
|
||||
|
||||
# Generic OAuth2 SSO
|
||||
social_auth_generic_oauth2_enabled: bool = False
|
||||
social_auth_generic_oauth2_client_id: Optional[str] = None
|
||||
social_auth_generic_oauth2_client_secret: Optional[str] = None
|
||||
social_auth_generic_oauth2_authorize_url: Optional[str] = None
|
||||
social_auth_generic_oauth2_token_url: Optional[str] = None
|
||||
social_auth_generic_oauth2_userinfo_url: Optional[str] = None
|
||||
social_auth_generic_oauth2_scope: str = "openid profile email"
|
||||
social_auth_generic_oauth2_name: str = "OAuth2"
|
||||
|
||||
# SAML2 SSO
|
||||
social_auth_saml2_enabled: bool = False
|
||||
social_auth_saml2_entity_id: Optional[str] = None
|
||||
social_auth_saml2_sso_url: Optional[str] = None
|
||||
social_auth_saml2_certificate: Optional[str] = None
|
||||
social_auth_saml2_name: str = "SAML2"
|
||||
|
||||
# Social Login Providers
|
||||
# Google OAuth2
|
||||
@@ -348,6 +380,11 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
# GitHub OAuth2
|
||||
social_auth_github_enabled: bool = False
|
||||
social_auth_github_client_id: Optional[str] = None
|
||||
social_auth_github_client_secret: Optional[str] = None
|
||||
|
||||
# Local user signup
|
||||
allow_local_signup: bool = Field(
|
||||
default=False,
|
||||
@@ -844,6 +881,11 @@ class Settings(BaseSettings):
|
||||
),
|
||||
)
|
||||
|
||||
# Telegram Bot
|
||||
telegram_bot_token: Optional[str] = None
|
||||
telegram_chat_id: Optional[str] = None
|
||||
telegram_enabled: bool = False
|
||||
|
||||
# Notification settings
|
||||
notification_urls: Union[List[str], str] = Field(
|
||||
default_factory=list,
|
||||
|
||||
@@ -262,6 +262,17 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"sso_auto_login": {
|
||||
"category": "Authentication",
|
||||
"description": (
|
||||
"Automatically redirect to SSO login when authentication is required. "
|
||||
"Skips the login page and sends users directly to the configured SSO provider."
|
||||
),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Social Login Providers
|
||||
"social_auth_google_enabled": {
|
||||
"category": "Social Login",
|
||||
@@ -422,6 +433,182 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_github_enabled": {
|
||||
"category": "Social Login",
|
||||
"description": (
|
||||
"Enable GitHub Sign-In. Requires SOCIAL_AUTH_GITHUB_CLIENT_ID and "
|
||||
"SOCIAL_AUTH_GITHUB_CLIENT_SECRET from GitHub Developer Settings."
|
||||
),
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
"help_link": "https://github.com/settings/developers",
|
||||
"help_link_label": "GitHub Developer Settings",
|
||||
},
|
||||
"social_auth_github_client_id": {
|
||||
"category": "Social Login",
|
||||
"description": "GitHub OAuth2 client ID from GitHub Developer Settings.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_github_client_secret": {
|
||||
"category": "Social Login",
|
||||
"description": "GitHub OAuth2 client secret from GitHub Developer Settings.",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
# Keycloak SSO
|
||||
"social_auth_keycloak_enabled": {
|
||||
"category": "Social Login",
|
||||
"description": "Enable Keycloak SSO. Requires server URL, realm, client ID, and client secret.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_keycloak_client_id": {
|
||||
"category": "Social Login",
|
||||
"description": "Keycloak OAuth2 client ID.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_keycloak_client_secret": {
|
||||
"category": "Social Login",
|
||||
"description": "Keycloak OAuth2 client secret.",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_keycloak_server_url": {
|
||||
"category": "Social Login",
|
||||
"description": "Keycloak server base URL (e.g. https://keycloak.example.com).",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_keycloak_realm": {
|
||||
"category": "Social Login",
|
||||
"description": "Keycloak realm name.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
# Generic OAuth2 SSO
|
||||
"social_auth_generic_oauth2_enabled": {
|
||||
"category": "Social Login",
|
||||
"description": "Enable a generic OAuth2 SSO provider.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_generic_oauth2_client_id": {
|
||||
"category": "Social Login",
|
||||
"description": "Generic OAuth2 client ID.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_generic_oauth2_client_secret": {
|
||||
"category": "Social Login",
|
||||
"description": "Generic OAuth2 client secret.",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_generic_oauth2_authorize_url": {
|
||||
"category": "Social Login",
|
||||
"description": "Generic OAuth2 authorization URL.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_generic_oauth2_token_url": {
|
||||
"category": "Social Login",
|
||||
"description": "Generic OAuth2 token endpoint URL.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_generic_oauth2_userinfo_url": {
|
||||
"category": "Social Login",
|
||||
"description": "Generic OAuth2 userinfo endpoint URL.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_generic_oauth2_scope": {
|
||||
"category": "Social Login",
|
||||
"description": "Space-separated list of OAuth2 scopes to request (default: openid profile email).",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_generic_oauth2_name": {
|
||||
"category": "Social Login",
|
||||
"description": "Display name for the generic OAuth2 provider button on the login page.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
# SAML2 SSO
|
||||
"social_auth_saml2_enabled": {
|
||||
"category": "Social Login",
|
||||
"description": "Enable SAML2 SSO authentication.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_saml2_entity_id": {
|
||||
"category": "Social Login",
|
||||
"description": "SAML2 Identity Provider Entity ID.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_saml2_sso_url": {
|
||||
"category": "Social Login",
|
||||
"description": "SAML2 Identity Provider SSO URL.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_saml2_certificate": {
|
||||
"category": "Social Login",
|
||||
"description": "SAML2 Identity Provider X.509 certificate (PEM format).",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
"social_auth_saml2_name": {
|
||||
"category": "Social Login",
|
||||
"description": "Display name for the SAML2 provider.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": True,
|
||||
},
|
||||
# AI Services
|
||||
"openai_api_key": {
|
||||
"category": "AI Services",
|
||||
@@ -1952,6 +2139,30 @@ SETTING_METADATA = {
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"telegram_enabled": {
|
||||
"category": "Notifications",
|
||||
"description": "Enable Telegram bot notifications.",
|
||||
"type": "boolean",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"telegram_bot_token": {
|
||||
"category": "Notifications",
|
||||
"description": "Telegram Bot API token from @BotFather.",
|
||||
"type": "string",
|
||||
"sensitive": True,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
"telegram_chat_id": {
|
||||
"category": "Notifications",
|
||||
"description": "Telegram chat ID to send notifications to.",
|
||||
"type": "string",
|
||||
"sensitive": False,
|
||||
"required": False,
|
||||
"restart_required": False,
|
||||
},
|
||||
# Notifications Settings
|
||||
"notification_urls": {
|
||||
"category": "Notifications",
|
||||
|
||||
@@ -195,6 +195,299 @@ async def credentials_page(request: Request, db: Session = Depends(get_db)):
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/connections")
|
||||
@require_login
|
||||
@require_admin_access
|
||||
async def connections_page(request: Request, db: Session = Depends(get_db)):
|
||||
"""
|
||||
Connections management page - admin only.
|
||||
|
||||
Allows administrators to configure external authentication providers,
|
||||
SSO settings, and service integrations through a wizard-like interface.
|
||||
"""
|
||||
try:
|
||||
from app.auth import OAUTH_CONFIGURED, SOCIAL_PROVIDERS
|
||||
|
||||
db_settings = get_all_settings_from_db(db)
|
||||
|
||||
def _get_effective(key: str):
|
||||
"""Return DB value if present, else fall back to settings attr."""
|
||||
if key in db_settings and db_settings[key] is not None:
|
||||
return db_settings[key]
|
||||
return getattr(settings, key, None)
|
||||
|
||||
def _is_truthy(val) -> bool:
|
||||
if isinstance(val, bool):
|
||||
return val
|
||||
if isinstance(val, str):
|
||||
return val.lower() in ("true", "1", "yes")
|
||||
return bool(val)
|
||||
|
||||
# Build service status list
|
||||
services = []
|
||||
|
||||
# --- SSO (Authentik / OIDC) ---
|
||||
services.append(
|
||||
{
|
||||
"key": "oidc",
|
||||
"name": settings.oauth_provider_name or "Single Sign-On",
|
||||
"icon": "fas fa-lock",
|
||||
"type": "SSO",
|
||||
"linked": OAUTH_CONFIGURED,
|
||||
"description": "OpenID Connect SSO provider",
|
||||
"settings_keys": [
|
||||
"authentik_client_id",
|
||||
"authentik_client_secret",
|
||||
"authentik_config_url",
|
||||
"oauth_provider_name",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- Google ---
|
||||
services.append(
|
||||
{
|
||||
"key": "google",
|
||||
"name": "Google",
|
||||
"icon": "fab fa-google",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "google" in SOCIAL_PROVIDERS,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_google_enabled",
|
||||
"social_auth_google_client_id",
|
||||
"social_auth_google_client_secret",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- GitHub ---
|
||||
services.append(
|
||||
{
|
||||
"key": "github",
|
||||
"name": "GitHub",
|
||||
"icon": "fab fa-github",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "github" in SOCIAL_PROVIDERS,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_github_enabled",
|
||||
"social_auth_github_client_id",
|
||||
"social_auth_github_client_secret",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- Microsoft ---
|
||||
services.append(
|
||||
{
|
||||
"key": "microsoft",
|
||||
"name": "Microsoft",
|
||||
"icon": "fab fa-microsoft",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "microsoft" in SOCIAL_PROVIDERS,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_microsoft_enabled",
|
||||
"social_auth_microsoft_client_id",
|
||||
"social_auth_microsoft_client_secret",
|
||||
"social_auth_microsoft_tenant",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- Apple ---
|
||||
services.append(
|
||||
{
|
||||
"key": "apple",
|
||||
"name": "Apple",
|
||||
"icon": "fab fa-apple",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "apple" in SOCIAL_PROVIDERS,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_apple_enabled",
|
||||
"social_auth_apple_client_id",
|
||||
"social_auth_apple_team_id",
|
||||
"social_auth_apple_key_id",
|
||||
"social_auth_apple_private_key",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- Dropbox ---
|
||||
services.append(
|
||||
{
|
||||
"key": "dropbox",
|
||||
"name": "Dropbox",
|
||||
"icon": "fab fa-dropbox",
|
||||
"type": "Sign-in authentication",
|
||||
"linked": "dropbox" in SOCIAL_PROVIDERS,
|
||||
"description": "Sign-in authentication",
|
||||
"settings_keys": [
|
||||
"social_auth_dropbox_enabled",
|
||||
"social_auth_dropbox_client_id",
|
||||
"social_auth_dropbox_client_secret",
|
||||
"social_auth_dropbox_use_global_credentials",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- Keycloak ---
|
||||
services.append(
|
||||
{
|
||||
"key": "keycloak",
|
||||
"name": "Keycloak",
|
||||
"icon": "fas fa-key",
|
||||
"type": "SSO",
|
||||
"linked": "keycloak" in SOCIAL_PROVIDERS,
|
||||
"description": "SSO",
|
||||
"settings_keys": [
|
||||
"social_auth_keycloak_enabled",
|
||||
"social_auth_keycloak_client_id",
|
||||
"social_auth_keycloak_client_secret",
|
||||
"social_auth_keycloak_server_url",
|
||||
"social_auth_keycloak_realm",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- Generic OAuth2 ---
|
||||
services.append(
|
||||
{
|
||||
"key": "generic_oauth2",
|
||||
"name": "Generic OAuth2",
|
||||
"icon": "fas fa-sign-in-alt",
|
||||
"type": "SSO",
|
||||
"linked": "generic_oauth2" in SOCIAL_PROVIDERS,
|
||||
"description": "SSO",
|
||||
"settings_keys": [
|
||||
"social_auth_generic_oauth2_enabled",
|
||||
"social_auth_generic_oauth2_client_id",
|
||||
"social_auth_generic_oauth2_client_secret",
|
||||
"social_auth_generic_oauth2_authorize_url",
|
||||
"social_auth_generic_oauth2_token_url",
|
||||
"social_auth_generic_oauth2_userinfo_url",
|
||||
"social_auth_generic_oauth2_scope",
|
||||
"social_auth_generic_oauth2_name",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- SAML2 ---
|
||||
_saml2_configured = bool(
|
||||
_is_truthy(_get_effective("social_auth_saml2_enabled"))
|
||||
and _get_effective("social_auth_saml2_sso_url")
|
||||
and _get_effective("social_auth_saml2_entity_id")
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"key": "saml2",
|
||||
"name": settings.social_auth_saml2_name or "SAML2",
|
||||
"icon": "fas fa-id-badge",
|
||||
"type": "SSO (SAML)",
|
||||
"linked": _saml2_configured,
|
||||
"description": "SSO (SAML)",
|
||||
"settings_keys": [
|
||||
"social_auth_saml2_enabled",
|
||||
"social_auth_saml2_entity_id",
|
||||
"social_auth_saml2_sso_url",
|
||||
"social_auth_saml2_certificate",
|
||||
"social_auth_saml2_name",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- SMTP Mail ---
|
||||
_smtp_configured = bool(_get_effective("email_host") and _get_effective("email_username"))
|
||||
services.append(
|
||||
{
|
||||
"key": "smtp",
|
||||
"name": "SMTP Mail",
|
||||
"icon": "fas fa-envelope",
|
||||
"type": "Email Notifications",
|
||||
"linked": _smtp_configured,
|
||||
"description": "Email Notifications",
|
||||
"settings_keys": [
|
||||
"email_host",
|
||||
"email_port",
|
||||
"email_username",
|
||||
"email_password",
|
||||
"email_use_tls",
|
||||
"email_sender",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# --- Telegram Bot ---
|
||||
_telegram_configured = bool(
|
||||
_is_truthy(_get_effective("telegram_enabled")) and _get_effective("telegram_bot_token")
|
||||
)
|
||||
services.append(
|
||||
{
|
||||
"key": "telegram",
|
||||
"name": "Telegram Bot",
|
||||
"icon": "fab fa-telegram",
|
||||
"type": "Notifications",
|
||||
"linked": _telegram_configured,
|
||||
"description": "Configure Telegram bot connectivity, access controls, and feedback behavior.",
|
||||
"settings_keys": [
|
||||
"telegram_enabled",
|
||||
"telegram_bot_token",
|
||||
"telegram_chat_id",
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
# Get setting details for the modal forms
|
||||
service_settings = {}
|
||||
for svc in services:
|
||||
svc_settings = []
|
||||
for skey in svc["settings_keys"]:
|
||||
meta = get_setting_metadata(skey)
|
||||
# Get current effective value
|
||||
val = _get_effective(skey)
|
||||
display_val = val
|
||||
if meta.get("sensitive") and val:
|
||||
display_val = mask_sensitive_value(val)
|
||||
svc_settings.append(
|
||||
{
|
||||
"key": skey,
|
||||
"value": val,
|
||||
"display_value": display_val if display_val is not None else "",
|
||||
"metadata": meta,
|
||||
}
|
||||
)
|
||||
service_settings[svc["key"]] = svc_settings
|
||||
|
||||
# Feature toggles
|
||||
sso_auto_login = _is_truthy(_get_effective("sso_auto_login"))
|
||||
qr_login_enabled = _is_truthy(_get_effective("qr_login_challenge_ttl_seconds"))
|
||||
frontend_url_configured = bool(_get_effective("public_base_url"))
|
||||
|
||||
return templates.TemplateResponse(
|
||||
"admin_connections.html",
|
||||
{
|
||||
"request": request,
|
||||
"services": services,
|
||||
"service_settings": service_settings,
|
||||
"sso_auto_login": sso_auto_login,
|
||||
"oauth_configured": OAUTH_CONFIGURED,
|
||||
"qr_login_enabled": qr_login_enabled,
|
||||
"frontend_url_configured": frontend_url_configured,
|
||||
"app_version": settings.version,
|
||||
},
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading connections page: {e}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="Failed to load connections page",
|
||||
)
|
||||
|
||||
|
||||
@router.get("/admin/settings/audit-log")
|
||||
@require_login
|
||||
@require_admin_access
|
||||
|
||||
@@ -15,6 +15,7 @@ This guide explains how to configure authentication for DocuElevate to secure yo
|
||||
| `AUTHENTIK_CLIENT_SECRET` | Client secret for OpenID Connect authentication |
|
||||
| `AUTHENTIK_CONFIG_URL` | OpenID Connect discovery URL |
|
||||
| `OAUTH_PROVIDER_NAME` | Display name for the OAuth provider button |
|
||||
| `SSO_AUTO_LOGIN` | Auto-redirect to SSO login (skips the login page) |
|
||||
|
||||
For a complete list of configuration options, see the [Configuration Guide](ConfigurationGuide.md).
|
||||
|
||||
@@ -24,7 +25,12 @@ DocuElevate supports multiple authentication methods that can be used independen
|
||||
|
||||
1. **Simple Authentication** - Basic username/password authentication managed by DocuElevate
|
||||
2. **OpenID Connect** - Integration with identity providers like Authentik, Keycloak, or Auth0
|
||||
3. **Social Login** - Sign in with Google, Microsoft, Apple, or Dropbox accounts (see [Social Login Setup Guide](SocialLoginSetup.md))
|
||||
3. **Social Login** - Sign in with Google, Microsoft, Apple, Dropbox, or GitHub accounts (see [Social Login Setup Guide](SocialLoginSetup.md))
|
||||
4. **Keycloak SSO** - Self-hosted identity management via Keycloak
|
||||
5. **Generic OAuth2** - Any OAuth2-compatible identity provider
|
||||
6. **SAML2** - Enterprise SSO via SAML 2.0
|
||||
|
||||
> **Admin Connections Page:** You can configure all authentication providers through the admin **Connections** page at `/admin/connections`.
|
||||
|
||||
## Session Security
|
||||
|
||||
|
||||
@@ -406,7 +406,7 @@ Credentials are encrypted at rest using Fernet encryption.
|
||||
|
||||
### Social Login Providers
|
||||
|
||||
Social login lets users sign in with their existing Google, Microsoft, Apple, or Dropbox accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md).
|
||||
Social login lets users sign in with their existing Google, Microsoft, Apple, Dropbox, or GitHub accounts. Each provider is independently enabled and configured. For detailed setup instructions see the [Social Login Setup Guide](SocialLoginSetup.md).
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|---|---|---|
|
||||
@@ -425,6 +425,33 @@ Social login lets users sign in with their existing Google, Microsoft, Apple, or
|
||||
| `SOCIAL_AUTH_DROPBOX_ENABLED` | Enable Dropbox Sign-In. | `false` |
|
||||
| `SOCIAL_AUTH_DROPBOX_CLIENT_ID` | Dropbox OAuth2 App Key. | *(empty)* |
|
||||
| `SOCIAL_AUTH_DROPBOX_CLIENT_SECRET` | Dropbox OAuth2 App Secret. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GITHUB_ENABLED` | Enable GitHub Sign-In. | `false` |
|
||||
| `SOCIAL_AUTH_GITHUB_CLIENT_ID` | GitHub OAuth2 client ID from GitHub Developer Settings. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth2 client secret. | *(empty)* |
|
||||
| `SSO_AUTO_LOGIN` | Automatically redirect to SSO login when authentication is required. | `false` |
|
||||
|
||||
### SSO Providers
|
||||
|
||||
| **Variable** | **Description** | **Default** |
|
||||
|---|---|---|
|
||||
| `SOCIAL_AUTH_KEYCLOAK_ENABLED` | Enable Keycloak SSO. | `false` |
|
||||
| `SOCIAL_AUTH_KEYCLOAK_CLIENT_ID` | Keycloak OAuth2 client ID. | *(empty)* |
|
||||
| `SOCIAL_AUTH_KEYCLOAK_CLIENT_SECRET` | Keycloak OAuth2 client secret. | *(empty)* |
|
||||
| `SOCIAL_AUTH_KEYCLOAK_SERVER_URL` | Keycloak server base URL (e.g. `https://keycloak.example.com`). | *(empty)* |
|
||||
| `SOCIAL_AUTH_KEYCLOAK_REALM` | Keycloak realm name. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED` | Enable a generic OAuth2 SSO provider. | `false` |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_ID` | Generic OAuth2 client ID. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_SECRET` | Generic OAuth2 client secret. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_AUTHORIZE_URL` | Generic OAuth2 authorization URL. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_TOKEN_URL` | Generic OAuth2 token endpoint URL. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_USERINFO_URL` | Generic OAuth2 userinfo endpoint URL. | *(empty)* |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_SCOPE` | Space-separated list of OAuth2 scopes. | `openid profile email` |
|
||||
| `SOCIAL_AUTH_GENERIC_OAUTH2_NAME` | Display name for the provider button. | `OAuth2` |
|
||||
| `SOCIAL_AUTH_SAML2_ENABLED` | Enable SAML2 SSO authentication. | `false` |
|
||||
| `SOCIAL_AUTH_SAML2_ENTITY_ID` | SAML2 Identity Provider Entity ID. | *(empty)* |
|
||||
| `SOCIAL_AUTH_SAML2_SSO_URL` | SAML2 Identity Provider SSO URL. | *(empty)* |
|
||||
| `SOCIAL_AUTH_SAML2_CERTIFICATE` | SAML2 Identity Provider X.509 certificate (PEM format). | *(empty)* |
|
||||
| `SOCIAL_AUTH_SAML2_NAME` | Display name for the SAML2 provider. | `SAML2` |
|
||||
|
||||
### Multi-User Mode
|
||||
|
||||
@@ -1375,6 +1402,9 @@ For detailed setup instructions, see the [Amazon S3 Setup Guide](AmazonS3Setup.m
|
||||
| `NOTIFY_ON_USER_SIGNUP` | Send admin notification when a new user signs up (`True`/`False`, default `True`) |
|
||||
| `NOTIFY_ON_PLAN_CHANGE` | Send admin notification when a user changes their subscription plan (`True`/`False`, default `True`) |
|
||||
| `NOTIFY_ON_PAYMENT_ISSUE` | Send admin notification when a payment issue is reported for a user (`True`/`False`, default `True`) |
|
||||
| `TELEGRAM_ENABLED` | Enable Telegram bot notifications. | `false` |
|
||||
| `TELEGRAM_BOT_TOKEN` | Telegram Bot API token from @BotFather. | *(empty)* |
|
||||
| `TELEGRAM_CHAT_ID` | Telegram chat ID to send notifications to. | *(empty)* |
|
||||
|
||||
#### User-Event Notifications
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Social Login Setup Guide
|
||||
|
||||
This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords.
|
||||
This guide explains how to configure social login providers (Google, Microsoft, Apple, Dropbox, GitHub) for DocuElevate. Social login lets your users sign in with their existing accounts, reducing friction and eliminating the need for separate passwords.
|
||||
|
||||
## Overview
|
||||
|
||||
DocuElevate supports four social login providers:
|
||||
DocuElevate supports five social login providers, plus additional SSO options:
|
||||
|
||||
| Provider | Protocol | Best For |
|
||||
|----------|----------|----------|
|
||||
@@ -12,9 +12,15 @@ DocuElevate supports four social login providers:
|
||||
| **Microsoft** | OAuth2 / OpenID Connect | Microsoft 365 / Azure AD organizations and personal Microsoft accounts |
|
||||
| **Apple** | OAuth2 / OpenID Connect | iOS/macOS users, privacy-focused users |
|
||||
| **Dropbox** | OAuth2 | Teams already using Dropbox as a storage destination |
|
||||
| **GitHub** | OAuth2 | Developer teams and open-source organizations |
|
||||
| **Keycloak** | OpenID Connect | Self-hosted identity management |
|
||||
| **Generic OAuth2** | OAuth2 | Any OAuth2-compatible identity provider |
|
||||
| **SAML2** | SAML 2.0 | Enterprise identity providers (Okta, ADFS, etc.) |
|
||||
|
||||
Each provider is **independently enabled** — you can use one, several, or all of them at the same time. Social login works alongside any other DocuElevate authentication method (simple auth, OIDC/Authentik, local signup).
|
||||
|
||||
> **Tip:** You can also configure providers through the admin **Connections** page at `/admin/connections`, which provides a wizard-like interface for setting up authentication services.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
Before configuring any social login provider, ensure:
|
||||
@@ -42,6 +48,7 @@ For example, if your DocuElevate instance is at `https://docuelevate.example.com
|
||||
| Microsoft | `https://docuelevate.example.com/social-callback/microsoft` |
|
||||
| Apple | `https://docuelevate.example.com/social-callback/apple` |
|
||||
| Dropbox | `https://docuelevate.example.com/social-callback/dropbox` |
|
||||
| GitHub | `https://docuelevate.example.com/social-callback/github` |
|
||||
|
||||
---
|
||||
|
||||
@@ -257,6 +264,86 @@ docker compose restart api worker
|
||||
|
||||
---
|
||||
|
||||
## GitHub Sign-In
|
||||
|
||||
### 1. Create an OAuth App in GitHub
|
||||
|
||||
1. Go to [GitHub Developer Settings](https://github.com/settings/developers)
|
||||
2. Click **OAuth Apps** → **New OAuth App**
|
||||
3. Fill in the required fields:
|
||||
- **Application name**: `DocuElevate` (or your preferred name)
|
||||
- **Homepage URL**: `https://docuelevate.example.com`
|
||||
- **Authorization callback URL**: `https://docuelevate.example.com/social-callback/github`
|
||||
4. Click **Register application**
|
||||
5. Copy the **Client ID**
|
||||
6. Click **Generate a new client secret** and copy the secret immediately (it won't be shown again)
|
||||
|
||||
### 2. Configure DocuElevate
|
||||
|
||||
Add these environment variables to your `.env` file:
|
||||
|
||||
```bash
|
||||
SOCIAL_AUTH_GITHUB_ENABLED=true
|
||||
SOCIAL_AUTH_GITHUB_CLIENT_ID=your-github-client-id
|
||||
SOCIAL_AUTH_GITHUB_CLIENT_SECRET=your-github-client-secret
|
||||
```
|
||||
|
||||
### 3. Restart DocuElevate
|
||||
|
||||
```bash
|
||||
docker compose restart api worker
|
||||
```
|
||||
|
||||
### Notes
|
||||
|
||||
- **Scopes requested**: `read:user` and `user:email` — DocuElevate only reads the user's public profile and primary email address
|
||||
- **Organization restrictions**: If your GitHub organization restricts OAuth app access, an organization owner must approve the DocuElevate OAuth app
|
||||
- **Private email**: If a user's email is private on GitHub, DocuElevate will request it via the `user:email` scope
|
||||
|
||||
---
|
||||
|
||||
## Keycloak SSO
|
||||
|
||||
### 1. Create a Client in Keycloak
|
||||
|
||||
1. Log in to your Keycloak admin console
|
||||
2. Select (or create) a realm
|
||||
3. Go to **Clients** → **Create client**
|
||||
4. Set **Client type** to `OpenID Connect`
|
||||
5. Set **Client ID** (e.g., `docuelevate`)
|
||||
6. Enable **Client authentication** (confidential)
|
||||
7. Add `https://docuelevate.example.com/social-callback/keycloak` to **Valid redirect URIs**
|
||||
8. Save and copy the **Client secret** from the **Credentials** tab
|
||||
|
||||
### 2. Configure DocuElevate
|
||||
|
||||
```bash
|
||||
SOCIAL_AUTH_KEYCLOAK_ENABLED=true
|
||||
SOCIAL_AUTH_KEYCLOAK_CLIENT_ID=docuelevate
|
||||
SOCIAL_AUTH_KEYCLOAK_CLIENT_SECRET=your-keycloak-client-secret
|
||||
SOCIAL_AUTH_KEYCLOAK_SERVER_URL=https://keycloak.example.com
|
||||
SOCIAL_AUTH_KEYCLOAK_REALM=your-realm-name
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Generic OAuth2 SSO
|
||||
|
||||
For any OAuth2-compatible identity provider not listed above:
|
||||
|
||||
```bash
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_ENABLED=true
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_ID=your-client-id
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_CLIENT_SECRET=your-client-secret
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_AUTHORIZE_URL=https://idp.example.com/oauth/authorize
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_TOKEN_URL=https://idp.example.com/oauth/token
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_USERINFO_URL=https://idp.example.com/oauth/userinfo
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_SCOPE=openid profile email
|
||||
SOCIAL_AUTH_GENERIC_OAUTH2_NAME=My Identity Provider
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Unified Authentication and Storage
|
||||
|
||||
One of the key advantages of social login in DocuElevate is the potential for **unified authentication** — using the same identity for both signing in and accessing cloud storage destinations:
|
||||
@@ -266,6 +353,7 @@ One of the key advantages of social login in DocuElevate is the potential for **
|
||||
| Google | Google Drive | User already has a Google identity for Drive integration |
|
||||
| Microsoft | OneDrive | User already has a Microsoft identity for OneDrive integration |
|
||||
| Dropbox | Dropbox | User already has a Dropbox identity for Dropbox integration |
|
||||
| GitHub | *(none)* | Developer-friendly authentication for technical teams |
|
||||
| Apple | *(none)* | Provides a familiar, privacy-respecting login option |
|
||||
|
||||
When a user signs in with a social provider that matches a configured storage destination, the administrator can leverage the same OAuth credentials or simplify the integration setup. Note that the storage integration credentials are configured separately in the admin settings — social login establishes the user's identity, not their storage permissions.
|
||||
|
||||
@@ -0,0 +1,334 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _("connections.title") }} - {{ _("app.name") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<main id="main-content" class="max-w-4xl mx-auto px-4 py-8">
|
||||
<div class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("connections.title") }}</h1>
|
||||
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _("connections.description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- SSO Auto Login Section -->
|
||||
{% if oauth_configured %}
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.sso_auto_login_title") }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only peer"
|
||||
{% if sso_auto_login %}checked{% endif %}
|
||||
onchange="toggleSetting('sso_auto_login', this.checked)">
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.sso_auto_login") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
{% endif %}
|
||||
|
||||
<!-- Mobile Phone Upload Section -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.mobile_upload_title") }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.mobile_upload_description") }}</p>
|
||||
{% if not frontend_url_configured %}
|
||||
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
{{ _("connections.frontend_url_note") }}
|
||||
</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div>
|
||||
<label class="relative inline-flex items-center cursor-pointer">
|
||||
<input type="checkbox" id="qr-upload-toggle" class="sr-only peer"
|
||||
{% if qr_login_enabled %}checked{% endif %} disabled>
|
||||
<div class="w-11 h-6 bg-gray-200 peer-focus:outline-none peer-focus:ring-2 peer-focus:ring-indigo-500 rounded-full peer dark:bg-gray-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-gray-600 peer-checked:bg-indigo-600" style="min-width:44px; min-height:24px;"></div>
|
||||
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Services Section -->
|
||||
<section class="mb-6">
|
||||
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("connections.unlinked_services") }}</h2>
|
||||
<div class="space-y-3">
|
||||
{% for service in services %}
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden border border-gray-100 dark:border-gray-700">
|
||||
<div class="p-5 flex flex-col sm:flex-row sm:items-center gap-4">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
|
||||
<i class="{{ service.icon }} text-lg" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate">{{ service.name }}</h3>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ service.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
{% if service.linked %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
||||
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("connections.linked") }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||
{{ _("connections.unlinked") }}
|
||||
</span>
|
||||
{% endif %}
|
||||
<button
|
||||
type="button"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||
style="min-height:44px; min-width:44px"
|
||||
onclick="openServiceModal('{{ service.key }}')"
|
||||
aria-label="{{ _('connections.configure') }} {{ service.name }}"
|
||||
>
|
||||
<i class="fas fa-cog mr-2" aria-hidden="true"></i>
|
||||
{{ _("connections.configure") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p class="text-xs text-gray-400 dark:text-gray-500 mt-4">
|
||||
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
||||
{{ _("connections.save_note") }}
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<!-- Service Configuration Modal -->
|
||||
<div id="service-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 hidden" role="dialog" aria-modal="true" aria-labelledby="service-modal-title" aria-describedby="service-modal-description">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]" onclick="event.stopPropagation()">
|
||||
<div class="px-6 pt-6 pb-2 flex items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
||||
<div>
|
||||
<h2 id="service-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white"></h2>
|
||||
<p id="service-modal-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.save_note") }}</p>
|
||||
</div>
|
||||
<button type="button" onclick="closeServiceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded-md p-1" style="min-height:44px; min-width:44px" aria-label="Close">
|
||||
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form id="service-form" onsubmit="saveServiceSettings(event)">
|
||||
<div id="service-fields" class="px-6 py-4 space-y-4">
|
||||
<!-- Dynamically populated -->
|
||||
</div>
|
||||
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
||||
<button type="button" onclick="closeServiceModal()" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700" style="min-height:44px;">
|
||||
{{ _("common.cancel") }}
|
||||
</button>
|
||||
<button type="submit" class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" style="min-height:44px;">
|
||||
<i class="fas fa-save mr-2" aria-hidden="true"></i>
|
||||
{{ _("common.save") }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Accessible notification banner -->
|
||||
<div id="save-notification" class="fixed top-4 right-4 z-[60] hidden max-w-sm" role="alert" aria-live="assertive">
|
||||
<div id="save-notification-inner" class="rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2">
|
||||
<i id="save-notification-icon" class="fas" aria-hidden="true"></i>
|
||||
<span id="save-notification-text"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const serviceSettings = {{ service_settings | tojson }};
|
||||
const settingMetadata = {};
|
||||
{% for svc_key, svc_fields in service_settings.items() %}
|
||||
{% for field in svc_fields %}
|
||||
settingMetadata['{{ field.key }}'] = {{ field.metadata | tojson }};
|
||||
{% endfor %}
|
||||
{% endfor %}
|
||||
|
||||
let currentServiceKey = null;
|
||||
|
||||
function openServiceModal(serviceKey) {
|
||||
currentServiceKey = serviceKey;
|
||||
const fields = serviceSettings[serviceKey] || [];
|
||||
const modal = document.getElementById('service-modal');
|
||||
const title = document.getElementById('service-modal-title');
|
||||
const container = document.getElementById('service-fields');
|
||||
|
||||
// Find service name from the page
|
||||
const serviceCards = document.querySelectorAll('[onclick*="' + serviceKey + '"]');
|
||||
let serviceName = serviceKey;
|
||||
if (serviceCards.length > 0) {
|
||||
const card = serviceCards[0].closest('.bg-white, .dark\\:bg-gray-800');
|
||||
if (card) {
|
||||
const h3 = card.querySelector('h3');
|
||||
if (h3) serviceName = h3.textContent.trim();
|
||||
}
|
||||
}
|
||||
title.textContent = '{{ _("connections.configure") }} ' + serviceName;
|
||||
|
||||
container.innerHTML = '';
|
||||
fields.forEach(function(field) {
|
||||
const meta = field.metadata || {};
|
||||
const div = document.createElement('div');
|
||||
|
||||
const label = document.createElement('label');
|
||||
label.setAttribute('for', 'field-' + field.key);
|
||||
label.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
||||
label.textContent = field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
||||
div.appendChild(label);
|
||||
|
||||
if (meta.description) {
|
||||
const desc = document.createElement('p');
|
||||
desc.className = 'text-xs text-gray-500 dark:text-gray-400 mb-2';
|
||||
desc.textContent = meta.description;
|
||||
div.appendChild(desc);
|
||||
}
|
||||
|
||||
if (meta.type === 'boolean') {
|
||||
const checkbox = document.createElement('input');
|
||||
checkbox.type = 'checkbox';
|
||||
checkbox.id = 'field-' + field.key;
|
||||
checkbox.name = field.key;
|
||||
checkbox.className = 'h-4 w-4 text-indigo-600 focus:ring-indigo-500 border-gray-300 rounded';
|
||||
const val = field.value;
|
||||
if (val === true || val === 'true' || val === '1' || val === 'True') {
|
||||
checkbox.checked = true;
|
||||
}
|
||||
div.appendChild(checkbox);
|
||||
} else {
|
||||
const input = document.createElement('input');
|
||||
input.type = meta.sensitive ? 'password' : 'text';
|
||||
input.id = 'field-' + field.key;
|
||||
input.name = field.key;
|
||||
input.className = 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-sm';
|
||||
input.placeholder = meta.sensitive ? '••••••••' : '';
|
||||
// Don't pre-fill sensitive values with masked data
|
||||
if (!meta.sensitive && field.value != null) {
|
||||
input.value = field.value;
|
||||
}
|
||||
div.appendChild(input);
|
||||
}
|
||||
|
||||
if (meta.help_link) {
|
||||
const link = document.createElement('a');
|
||||
link.href = meta.help_link;
|
||||
link.target = '_blank';
|
||||
link.rel = 'noopener noreferrer';
|
||||
link.className = 'text-xs text-indigo-600 hover:text-indigo-500 mt-1 inline-block';
|
||||
link.textContent = meta.help_link_label || 'Documentation';
|
||||
div.appendChild(link);
|
||||
}
|
||||
|
||||
container.appendChild(div);
|
||||
});
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
// Focus first input
|
||||
setTimeout(function() {
|
||||
const firstInput = container.querySelector('input');
|
||||
if (firstInput) firstInput.focus();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function closeServiceModal() {
|
||||
document.getElementById('service-modal').classList.add('hidden');
|
||||
currentServiceKey = null;
|
||||
}
|
||||
|
||||
function saveServiceSettings(event) {
|
||||
event.preventDefault();
|
||||
if (!currentServiceKey) return;
|
||||
|
||||
const fields = serviceSettings[currentServiceKey] || [];
|
||||
const updates = {};
|
||||
|
||||
fields.forEach(function(field) {
|
||||
const meta = field.metadata || {};
|
||||
const el = document.getElementById('field-' + field.key);
|
||||
if (!el) return;
|
||||
|
||||
if (meta.type === 'boolean') {
|
||||
updates[field.key] = el.checked ? 'true' : 'false';
|
||||
} else {
|
||||
// Only send non-empty values for sensitive fields (empty = no change)
|
||||
if (meta.sensitive && !el.value) return;
|
||||
updates[field.key] = el.value;
|
||||
}
|
||||
});
|
||||
|
||||
// Save each setting via the API
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const promises = Object.entries(updates).map(function([key, value]) {
|
||||
return fetch('/api/settings/' + key, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ value: value }),
|
||||
});
|
||||
});
|
||||
|
||||
Promise.all(promises)
|
||||
.then(function(responses) {
|
||||
const allOk = responses.every(function(r) { return r.ok; });
|
||||
if (allOk) {
|
||||
closeServiceModal();
|
||||
location.reload();
|
||||
} else {
|
||||
showNotification('error', 'Some settings failed to save. Please check the values and try again.');
|
||||
}
|
||||
})
|
||||
.catch(function(err) {
|
||||
console.error('Error saving settings:', err);
|
||||
showNotification('error', 'Failed to save settings. Please try again.');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSetting(key, value) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
fetch('/api/settings/' + key, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ value: value ? 'true' : 'false' }),
|
||||
}).catch(function(err) {
|
||||
console.error('Error toggling setting:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Close modal on Escape
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') closeServiceModal();
|
||||
});
|
||||
|
||||
// Close modal on backdrop click
|
||||
document.getElementById('service-modal')?.addEventListener('click', function(e) {
|
||||
if (e.target === this) closeServiceModal();
|
||||
});
|
||||
|
||||
function showNotification(type, message) {
|
||||
var el = document.getElementById('save-notification');
|
||||
var inner = document.getElementById('save-notification-inner');
|
||||
var icon = document.getElementById('save-notification-icon');
|
||||
var text = document.getElementById('save-notification-text');
|
||||
if (!el) return;
|
||||
text.textContent = message;
|
||||
if (type === 'error') {
|
||||
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||
icon.className = 'fas fa-exclamation-circle';
|
||||
} else {
|
||||
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
||||
icon.className = 'fas fa-check-circle';
|
||||
}
|
||||
el.classList.remove('hidden');
|
||||
setTimeout(function() { el.classList.add('hidden'); }, 5000);
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -155,6 +155,9 @@
|
||||
<a href="/admin/credentials" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
||||
</a>
|
||||
<a href="/admin/connections" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-plug w-4 mr-2 text-purple-500" aria-hidden="true"></i> {{ _("nav.connections") }}
|
||||
</a>
|
||||
<a href="/admin/files" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
||||
</a>
|
||||
@@ -429,6 +432,9 @@
|
||||
<a href="/admin/credentials" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
||||
</a>
|
||||
<a href="/admin/connections" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-plug mr-2 text-purple-400" aria-hidden="true"></i> {{ _("nav.connections") }}
|
||||
</a>
|
||||
<a href="/admin/files" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
||||
</a>
|
||||
|
||||
@@ -617,6 +617,22 @@
|
||||
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
|
||||
"cookie_policy.s5_privacy_link": "Privacy Notice",
|
||||
"cookie_policy.s5_terms_link": "Terms of Service",
|
||||
"connections.configure": "Configure",
|
||||
"connections.configured": "Connected",
|
||||
"connections.description": "Configure external authentication providers like OAuth2 and SAML.",
|
||||
"connections.frontend_url_note": "Note: Requires Frontend URL to be configured. Configure in System Settings.",
|
||||
"connections.linked": "Linked",
|
||||
"connections.mobile_upload_description": "Allow users to upload files from mobile devices by scanning a QR code.",
|
||||
"connections.mobile_upload_title": "Mobile Phone Upload",
|
||||
"connections.qr_code_enabled": "Enable QR Code Upload",
|
||||
"connections.save_note": "Changes to authentication providers require a restart to take effect.",
|
||||
"connections.sso_auto_login": "Enable SSO Auto Login",
|
||||
"connections.sso_auto_login_description": "Automatically redirect to SSO login when authentication is required.",
|
||||
"connections.sso_auto_login_title": "SSO Auto Login",
|
||||
"connections.title": "Connections",
|
||||
"connections.unconfigure": "Disconnect",
|
||||
"connections.unlinked": "Unlinked",
|
||||
"connections.unlinked_services": "Services",
|
||||
"credentials.col_action": "Action",
|
||||
"credentials.col_credential": "Credential",
|
||||
"credentials.col_source": "Source",
|
||||
@@ -1230,6 +1246,7 @@
|
||||
"nav.api_docs": "API Docs",
|
||||
"nav.api_tokens": "API Tokens",
|
||||
"nav.backup_restore": "Backup & Restore",
|
||||
"nav.connections": "Connections",
|
||||
"nav.credentials": "Credentials",
|
||||
"nav.dark_mode": "Dark Mode",
|
||||
"nav.dashboard": "Dashboard",
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Tests for the Connections admin page and new authentication providers."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import status
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestDropboxComplianceFix:
|
||||
"""Tests for the Dropbox userinfo compliance fix."""
|
||||
|
||||
def test_dropbox_compliance_fix_adds_sub(self):
|
||||
"""Test that compliance fix adds 'sub' from account_id."""
|
||||
from app.auth import _dropbox_userinfo_compliance_fix
|
||||
|
||||
data = {"account_id": "dbid:abc123", "email": "test@example.com"}
|
||||
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
|
||||
assert result["sub"] == "dbid:abc123"
|
||||
|
||||
def test_dropbox_compliance_fix_does_not_overwrite_sub(self):
|
||||
"""Test that compliance fix preserves existing 'sub'."""
|
||||
from app.auth import _dropbox_userinfo_compliance_fix
|
||||
|
||||
data = {"account_id": "dbid:abc123", "sub": "existing-sub"}
|
||||
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
|
||||
assert result["sub"] == "existing-sub"
|
||||
|
||||
def test_dropbox_compliance_fix_normalizes_name(self):
|
||||
"""Test that compliance fix normalizes nested name object."""
|
||||
from app.auth import _dropbox_userinfo_compliance_fix
|
||||
|
||||
data = {"name": {"display_name": "John Doe", "given_name": "John"}}
|
||||
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
|
||||
assert result["name"] == "John Doe"
|
||||
|
||||
def test_dropbox_compliance_fix_handles_no_name(self):
|
||||
"""Test compliance fix works when no name is present."""
|
||||
from app.auth import _dropbox_userinfo_compliance_fix
|
||||
|
||||
data = {"email": "test@example.com"}
|
||||
result = _dropbox_userinfo_compliance_fix(None, None, None, data)
|
||||
assert "email" in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestGitHubNormalization:
|
||||
"""Tests for GitHub userinfo normalization."""
|
||||
|
||||
def test_github_normalize_standard(self):
|
||||
"""Test GitHub userinfo normalization with standard response."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
raw = {
|
||||
"id": 12345,
|
||||
"login": "octocat",
|
||||
"name": "The Octocat",
|
||||
"email": "octocat@github.com",
|
||||
"avatar_url": "https://avatars.githubusercontent.com/u/12345",
|
||||
}
|
||||
result = _normalize_social_userinfo("github", {}, raw)
|
||||
assert result["sub"] == "12345"
|
||||
assert result["email"] == "octocat@github.com"
|
||||
assert result["name"] == "The Octocat"
|
||||
assert result["preferred_username"] == "octocat"
|
||||
assert result["picture"] == "https://avatars.githubusercontent.com/u/12345"
|
||||
|
||||
def test_github_normalize_no_name_uses_login(self):
|
||||
"""Test GitHub normalization falls back to login when name is empty."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
raw = {"id": 12345, "login": "octocat", "name": "", "email": "octocat@github.com"}
|
||||
result = _normalize_social_userinfo("github", {}, raw)
|
||||
assert result["name"] == "octocat"
|
||||
|
||||
def test_github_normalize_missing_fields(self):
|
||||
"""Test GitHub normalization handles missing fields gracefully."""
|
||||
from app.auth import _normalize_social_userinfo
|
||||
|
||||
result = _normalize_social_userinfo("github", {}, {})
|
||||
assert result["sub"] == ""
|
||||
assert result["email"] == ""
|
||||
assert result["name"] == ""
|
||||
assert result["preferred_username"] == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSSOAutoLogin:
|
||||
"""Tests for SSO Auto Login configuration."""
|
||||
|
||||
def test_sso_auto_login_default_false(self):
|
||||
"""Test that SSO auto login defaults to False."""
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
auth_enabled=True,
|
||||
)
|
||||
assert s.sso_auto_login is False
|
||||
|
||||
def test_sso_auto_login_can_be_enabled(self):
|
||||
"""Test that SSO auto login can be set to True."""
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(
|
||||
_env_file=None,
|
||||
auth_enabled=True,
|
||||
sso_auto_login=True,
|
||||
)
|
||||
assert s.sso_auto_login is True
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestNewConfigFields:
|
||||
"""Tests for new configuration fields."""
|
||||
|
||||
def test_github_config_defaults(self):
|
||||
"""Test GitHub social auth config defaults."""
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(_env_file=None, auth_enabled=True)
|
||||
assert s.social_auth_github_enabled is False
|
||||
assert s.social_auth_github_client_id is None
|
||||
assert s.social_auth_github_client_secret is None
|
||||
|
||||
def test_keycloak_config_defaults(self):
|
||||
"""Test Keycloak social auth config defaults."""
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(_env_file=None, auth_enabled=True)
|
||||
assert s.social_auth_keycloak_enabled is False
|
||||
assert s.social_auth_keycloak_client_id is None
|
||||
assert s.social_auth_keycloak_server_url is None
|
||||
assert s.social_auth_keycloak_realm is None
|
||||
|
||||
def test_generic_oauth2_config_defaults(self):
|
||||
"""Test Generic OAuth2 config defaults."""
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(_env_file=None, auth_enabled=True)
|
||||
assert s.social_auth_generic_oauth2_enabled is False
|
||||
assert s.social_auth_generic_oauth2_scope == "openid profile email"
|
||||
assert s.social_auth_generic_oauth2_name == "OAuth2"
|
||||
|
||||
def test_saml2_config_defaults(self):
|
||||
"""Test SAML2 config defaults."""
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(_env_file=None, auth_enabled=True)
|
||||
assert s.social_auth_saml2_enabled is False
|
||||
assert s.social_auth_saml2_name == "SAML2"
|
||||
|
||||
def test_telegram_config_defaults(self):
|
||||
"""Test Telegram config defaults."""
|
||||
from app.config import Settings
|
||||
|
||||
s = Settings(_env_file=None, auth_enabled=True)
|
||||
assert s.telegram_enabled is False
|
||||
assert s.telegram_bot_token is None
|
||||
assert s.telegram_chat_id is None
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestSettingsMetadata:
|
||||
"""Tests that new settings have metadata entries."""
|
||||
|
||||
def test_github_settings_have_metadata(self):
|
||||
"""Test GitHub settings are in SETTING_METADATA."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "social_auth_github_enabled" in SETTING_METADATA
|
||||
assert "social_auth_github_client_id" in SETTING_METADATA
|
||||
assert "social_auth_github_client_secret" in SETTING_METADATA
|
||||
|
||||
def test_keycloak_settings_have_metadata(self):
|
||||
"""Test Keycloak settings are in SETTING_METADATA."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "social_auth_keycloak_enabled" in SETTING_METADATA
|
||||
assert "social_auth_keycloak_client_id" in SETTING_METADATA
|
||||
assert "social_auth_keycloak_server_url" in SETTING_METADATA
|
||||
assert "social_auth_keycloak_realm" in SETTING_METADATA
|
||||
|
||||
def test_generic_oauth2_settings_have_metadata(self):
|
||||
"""Test Generic OAuth2 settings are in SETTING_METADATA."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "social_auth_generic_oauth2_enabled" in SETTING_METADATA
|
||||
assert "social_auth_generic_oauth2_authorize_url" in SETTING_METADATA
|
||||
assert "social_auth_generic_oauth2_token_url" in SETTING_METADATA
|
||||
|
||||
def test_saml2_settings_have_metadata(self):
|
||||
"""Test SAML2 settings are in SETTING_METADATA."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "social_auth_saml2_enabled" in SETTING_METADATA
|
||||
assert "social_auth_saml2_sso_url" in SETTING_METADATA
|
||||
assert "social_auth_saml2_entity_id" in SETTING_METADATA
|
||||
|
||||
def test_telegram_settings_have_metadata(self):
|
||||
"""Test Telegram settings are in SETTING_METADATA."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "telegram_enabled" in SETTING_METADATA
|
||||
assert "telegram_bot_token" in SETTING_METADATA
|
||||
assert "telegram_chat_id" in SETTING_METADATA
|
||||
|
||||
def test_sso_auto_login_has_metadata(self):
|
||||
"""Test SSO auto login has metadata."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "sso_auto_login" in SETTING_METADATA
|
||||
meta = SETTING_METADATA["sso_auto_login"]
|
||||
assert meta["category"] == "Authentication"
|
||||
assert meta["type"] == "boolean"
|
||||
|
||||
def test_github_category_is_social_login(self):
|
||||
"""Test GitHub settings are in Social Login category."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert SETTING_METADATA["social_auth_github_enabled"]["category"] == "Social Login"
|
||||
|
||||
def test_github_secret_is_sensitive(self):
|
||||
"""Test GitHub client secret is marked sensitive."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert SETTING_METADATA["social_auth_github_client_secret"]["sensitive"] is True
|
||||
|
||||
def test_github_has_help_link(self):
|
||||
"""Test GitHub has a help link to developer settings."""
|
||||
from app.utils.settings_service import SETTING_METADATA
|
||||
|
||||
assert "help_link" in SETTING_METADATA["social_auth_github_enabled"]
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestConnectionsPageRoute:
|
||||
"""Tests for the /admin/connections route."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_page_non_admin_redirected(self):
|
||||
"""Test that non-admin users are redirected from connections page."""
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"is_admin": False}}
|
||||
mock_db = MagicMock()
|
||||
|
||||
# The require_admin_access decorator should handle this, so we test the decorator
|
||||
from app.views.settings import require_admin_access
|
||||
|
||||
@require_admin_access
|
||||
async def dummy_view(request):
|
||||
return "success"
|
||||
|
||||
result = await dummy_view(mock_request)
|
||||
assert result.status_code == status.HTTP_302_FOUND
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connections_page_returns_services(self):
|
||||
"""Test that connections page includes expected services in context."""
|
||||
from app.views.settings import connections_page
|
||||
|
||||
mock_request = MagicMock()
|
||||
mock_request.session = {"user": {"is_admin": True}}
|
||||
mock_db = MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.views.settings.get_all_settings_from_db", return_value={}),
|
||||
patch("app.views.settings.templates") as mock_templates,
|
||||
patch("app.views.settings.SETTING_METADATA", {}),
|
||||
patch("app.auth.OAUTH_CONFIGURED", False),
|
||||
patch("app.auth.SOCIAL_PROVIDERS", {}),
|
||||
patch("app.views.settings.get_setting_metadata", return_value={}),
|
||||
):
|
||||
mock_templates.TemplateResponse.return_value = "response"
|
||||
result = await connections_page(mock_request, db=mock_db)
|
||||
|
||||
# Check TemplateResponse was called
|
||||
mock_templates.TemplateResponse.assert_called_once()
|
||||
call_args = mock_templates.TemplateResponse.call_args
|
||||
template_name = call_args[0][0]
|
||||
context = call_args[0][1]
|
||||
|
||||
assert template_name == "admin_connections.html"
|
||||
assert "services" in context
|
||||
assert "service_settings" in context
|
||||
assert "sso_auto_login" in context
|
||||
|
||||
# Verify expected service keys
|
||||
service_keys = [s["key"] for s in context["services"]]
|
||||
assert "google" in service_keys
|
||||
assert "github" in service_keys
|
||||
assert "keycloak" in service_keys
|
||||
assert "generic_oauth2" in service_keys
|
||||
assert "saml2" in service_keys
|
||||
assert "smtp" in service_keys
|
||||
assert "telegram" in service_keys
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
class TestTranslationKeys:
|
||||
"""Tests for new translation keys."""
|
||||
|
||||
def test_connections_translation_keys_exist(self):
|
||||
"""Test that connections translation keys are in en.json."""
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
en_path = Path(__file__).parents[1] / "frontend" / "translations" / "en.json"
|
||||
translations = json.loads(en_path.read_text())
|
||||
|
||||
expected_keys = [
|
||||
"connections.title",
|
||||
"connections.description",
|
||||
"connections.configure",
|
||||
"connections.linked",
|
||||
"connections.unlinked",
|
||||
"connections.sso_auto_login",
|
||||
"connections.sso_auto_login_title",
|
||||
"connections.sso_auto_login_description",
|
||||
"connections.mobile_upload_title",
|
||||
"connections.qr_code_enabled",
|
||||
"connections.unlinked_services",
|
||||
"nav.connections",
|
||||
]
|
||||
for key in expected_keys:
|
||||
assert key in translations, f"Missing translation key: {key}"
|
||||
Reference in New Issue
Block a user