feat(auth): add GitHub, Keycloak, Generic OAuth2 social login providers and connections page
- Fix Dropbox social login: add token_access_type=offline and userinfo compliance fix - Add GitHub social login provider with OAuth2 registration and userinfo normalization - Add Keycloak SSO provider with OpenID Connect discovery - Add Generic OAuth2 provider for custom SSO integrations - Add SAML2 SSO config fields (config only, no flow implementation) - Add SSO auto-login: redirect directly to SSO when configured - Add Telegram bot notification config fields - Add admin Connections page for managing auth providers and integrations - Add navigation links and translation keys for connections page Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+105
-4
@@ -102,6 +102,24 @@ if AUTH_ENABLED and settings.social_auth_apple_enabled:
|
|||||||
else:
|
else:
|
||||||
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")
|
||||||
|
|
||||||
|
|
||||||
|
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.
|
||||||
|
"""
|
||||||
|
# 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:
|
if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
||||||
# Determine which credentials to use for Dropbox social login
|
# Determine which credentials to use for Dropbox social login
|
||||||
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
_dropbox_client_id = settings.social_auth_dropbox_client_id
|
||||||
@@ -118,13 +136,77 @@ if AUTH_ENABLED and settings.social_auth_dropbox_enabled:
|
|||||||
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",
|
||||||
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"}
|
SOCIAL_PROVIDERS["dropbox"] = {"name": "Dropbox", "icon": "fab fa-dropbox", "color": "blue"}
|
||||||
logger.info("Social login provider registered: Dropbox")
|
logger.info("Social login provider registered: Dropbox")
|
||||||
else:
|
else:
|
||||||
logger.warning("SOCIAL_AUTH_DROPBOX_ENABLED=true but client ID/secret not configured")
|
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()
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
@@ -348,13 +430,21 @@ async def login(request: Request):
|
|||||||
get_client_ip(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 and not error and not message:
|
||||||
|
return RedirectResponse(url="/oauth-login", status_code=status.HTTP_302_FOUND)
|
||||||
|
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"login.html",
|
"login.html",
|
||||||
{
|
{
|
||||||
"request": request,
|
"request": request,
|
||||||
"error": request.query_params.get("error"),
|
"error": error,
|
||||||
"message": request.query_params.get("message"),
|
"message": message,
|
||||||
"show_oauth": OAUTH_CONFIGURED,
|
"show_oauth": show_oauth,
|
||||||
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
"oauth_provider_name": OAUTH_PROVIDER_NAME,
|
||||||
"social_providers": SOCIAL_PROVIDERS,
|
"social_providers": SOCIAL_PROVIDERS,
|
||||||
"app_version": settings.version,
|
"app_version": settings.version,
|
||||||
@@ -440,6 +530,17 @@ def _normalize_social_userinfo(provider: str, token: dict, raw_userinfo: dict |
|
|||||||
"picture": userinfo.get("profile_photo_url", ""),
|
"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)
|
# Standard OIDC providers (Google, Microsoft, Apple)
|
||||||
return {
|
return {
|
||||||
"sub": userinfo.get("sub", ""),
|
"sub": userinfo.get("sub", ""),
|
||||||
|
|||||||
@@ -305,6 +305,38 @@ class Settings(BaseSettings):
|
|||||||
authentik_client_secret: Optional[str] = None
|
authentik_client_secret: Optional[str] = None
|
||||||
authentik_config_url: Optional[str] = None
|
authentik_config_url: Optional[str] = None
|
||||||
oauth_provider_name: Optional[str] = None # Name to display for the OAuth provider
|
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
|
# Social Login Providers
|
||||||
# Google OAuth2
|
# 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
|
# Local user signup
|
||||||
allow_local_signup: bool = Field(
|
allow_local_signup: bool = Field(
|
||||||
default=False,
|
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 settings
|
||||||
notification_urls: Union[List[str], str] = Field(
|
notification_urls: Union[List[str], str] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
|
|||||||
@@ -262,6 +262,17 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"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 Login Providers
|
||||||
"social_auth_google_enabled": {
|
"social_auth_google_enabled": {
|
||||||
"category": "Social Login",
|
"category": "Social Login",
|
||||||
@@ -422,6 +433,182 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_required": True,
|
"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
|
# AI Services
|
||||||
"openai_api_key": {
|
"openai_api_key": {
|
||||||
"category": "AI Services",
|
"category": "AI Services",
|
||||||
@@ -1952,6 +2139,30 @@ SETTING_METADATA = {
|
|||||||
"required": False,
|
"required": False,
|
||||||
"restart_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
|
# Notifications Settings
|
||||||
"notification_urls": {
|
"notification_urls": {
|
||||||
"category": "Notifications",
|
"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")
|
@router.get("/admin/settings/audit-log")
|
||||||
@require_login
|
@require_login
|
||||||
@require_admin_access
|
@require_admin_access
|
||||||
|
|||||||
@@ -0,0 +1,305 @@
|
|||||||
|
{% 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">
|
||||||
|
<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">
|
||||||
|
<h2 id="service-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white"></h2>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<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 {
|
||||||
|
alert('Some settings failed to save. Please check the values and try again.');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(function(err) {
|
||||||
|
console.error('Error saving settings:', err);
|
||||||
|
alert('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();
|
||||||
|
});
|
||||||
|
</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">
|
<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") }}
|
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
||||||
</a>
|
</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">
|
<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") }}
|
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
||||||
</a>
|
</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">
|
<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") }}
|
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> {{ _("nav.credentials") }}
|
||||||
</a>
|
</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">
|
<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") }}
|
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.file_manager") }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -617,6 +617,22 @@
|
|||||||
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
|
"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_privacy_link": "Privacy Notice",
|
||||||
"cookie_policy.s5_terms_link": "Terms of Service",
|
"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_action": "Action",
|
||||||
"credentials.col_credential": "Credential",
|
"credentials.col_credential": "Credential",
|
||||||
"credentials.col_source": "Source",
|
"credentials.col_source": "Source",
|
||||||
@@ -1230,6 +1246,7 @@
|
|||||||
"nav.api_docs": "API Docs",
|
"nav.api_docs": "API Docs",
|
||||||
"nav.api_tokens": "API Tokens",
|
"nav.api_tokens": "API Tokens",
|
||||||
"nav.backup_restore": "Backup & Restore",
|
"nav.backup_restore": "Backup & Restore",
|
||||||
|
"nav.connections": "Connections",
|
||||||
"nav.credentials": "Credentials",
|
"nav.credentials": "Credentials",
|
||||||
"nav.dark_mode": "Dark Mode",
|
"nav.dark_mode": "Dark Mode",
|
||||||
"nav.dashboard": "Dashboard",
|
"nav.dashboard": "Dashboard",
|
||||||
|
|||||||
Reference in New Issue
Block a user