feat: Enhance integrations management with config UI and improved status display

- Add integration-specific configuration support via get_config_schema()
  - Dynamic form generation for config fields (string, boolean, text, JSON, url)
  - Save integration config to database (jira_url, jql, realm_id, etc.)
  - Support for all field types with validation and help text

- Improve connection status visualization
  - Enhanced status badges (Connected/Not Connected, Sync OK/Error)
  - Display last sync timestamp and error messages
  - Grid layout for better information hierarchy
  - Clear visual feedback for connection health

- Enhance sync operations
  - Update last_sync_at, last_sync_status, and last_error fields
  - Log sync events with metadata to IntegrationEvent table
  - Better error handling and user feedback

- Clean up integrations list page
  - Remove duplicate 'Your Active Integrations' table section
  - Keep only card-based view with all necessary information
  - Simplified, cleaner interface

- Fix f-string syntax errors in QuickBooks connector
  - Extract string replacements outside f-string expressions
  - Fix lines 382 and 444 that prevented connector registration

- Update navigation and admin dashboard
  - Move integrations link from admin menu to Tools & Data menu
  - Update admin dashboard shortcut to new integrations route

All integrations now have full configuration UI support, better status
feedback, and improved user experience.
This commit is contained in:
Dries Peeters
2025-12-29 16:03:46 +01:00
parent 0c310736c1
commit bd16c664ac
7 changed files with 839 additions and 124 deletions
+6 -2
View File
@@ -379,7 +379,9 @@ class QuickBooksConnector(BaseConnector):
# QuickBooks query syntax: SELECT * FROM Customer WHERE DisplayName = 'CustomerName'
# URL encode the query parameter
from urllib.parse import quote
query = f"SELECT * FROM Customer WHERE DisplayName = '{customer_name.replace(\"'\", \"''\")}'"
# Escape single quotes for SQL (replace ' with '')
escaped_name = customer_name.replace("'", "''")
query = f"SELECT * FROM Customer WHERE DisplayName = '{escaped_name}'"
query_url = f"/v3/company/{realm_id}/query?query={quote(query)}"
customers_response = self._api_request(
@@ -439,7 +441,9 @@ class QuickBooksConnector(BaseConnector):
try:
# Query QuickBooks for item by Name
from urllib.parse import quote
query = f"SELECT * FROM Item WHERE Name = '{item_qb_name.replace(\"'\", \"''\")}'"
# Escape single quotes for SQL (replace ' with '')
escaped_name = item_qb_name.replace("'", "''")
query = f"SELECT * FROM Item WHERE Name = '{escaped_name}'"
query_url = f"/v3/company/{realm_id}/query?query={quote(query)}"
items_response = self._api_request(
+312 -12
View File
@@ -12,6 +12,12 @@ from app.utils.db import safe_commit
import secrets
import logging
# Import registry to ensure connectors are registered
try:
from app.integrations import registry # noqa: F401
except ImportError:
pass
logger = logging.getLogger(__name__)
integrations_bp = Blueprint("integrations", __name__)
@@ -44,13 +50,12 @@ def connect_integration(provider):
flash(_("Integration provider not available."), "error")
return redirect(url_for("integrations.list_integrations"))
# Trello doesn't use OAuth - redirect to admin setup
# Trello doesn't use OAuth - redirect to manage page
if provider == "trello":
if not current_user.is_admin:
flash(_("Trello integration must be configured by an administrator."), "error")
return redirect(url_for("integrations.list_integrations"))
flash(_("Trello uses API key authentication. Please configure it in Admin → Integrations."), "info")
return redirect(url_for("admin.integration_setup", provider=provider))
return redirect(url_for("integrations.manage_integration", provider=provider))
# CalDAV doesn't use OAuth - redirect to setup form
if provider == "caldav_calendar":
@@ -107,12 +112,12 @@ def connect_integration(provider):
flash(
_("Google Calendar OAuth credentials need to be configured first. Redirecting to setup..."), "info"
)
return redirect(url_for("admin.integration_setup", provider=provider))
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Google Calendar integration needs to be configured by an administrator first."), "warning")
elif current_user.is_admin:
flash(_("OAuth credentials not configured. Please set them up in Admin → Integrations."), "error")
return redirect(url_for("admin.integration_setup", provider=provider))
flash(_("OAuth credentials not configured. Please configure them first."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Integration not configured. Please ask an administrator to set up OAuth credentials."), "error")
return redirect(url_for("integrations.list_integrations"))
@@ -191,10 +196,8 @@ def oauth_callback(provider):
"warning",
)
# Redirect to admin setup page for global integrations, view page for per-user
if integration.is_global and current_user.is_admin:
return redirect(url_for("admin.integration_setup", provider=provider))
return redirect(url_for("integrations.view_integration", integration_id=integration.id))
# Redirect to manage page
return redirect(url_for("integrations.manage_integration", provider=provider))
except Exception as e:
logger.error(f"Error in OAuth callback for {provider}: {e}")
@@ -202,6 +205,275 @@ def oauth_callback(provider):
return redirect(url_for("integrations.list_integrations"))
@integrations_bp.route("/integrations/<provider>/manage", methods=["GET", "POST"])
@login_required
def manage_integration(provider):
"""Manage an integration: configure OAuth credentials (admin) and connection management (all users)."""
from app.models import Settings
# Ensure registry is loaded
try:
from app.integrations import registry # noqa: F401
except ImportError:
pass
service = IntegrationService()
# Get connector class if available, otherwise use defaults
connector_class = service._connector_registry.get(provider)
if not connector_class:
# Provider not in registry - create a minimal connector class info
class MinimalConnector:
display_name = provider.replace("_", " ").title()
description = ""
icon = "plug"
connector_class = MinimalConnector
# Log warning but continue
logger.warning(f"Provider {provider} not found in registry, using defaults")
settings = Settings.get_settings()
# Get or create integration
is_global = provider not in ("google_calendar", "caldav_calendar")
integration = None
if is_global:
integration = service.get_global_integration(provider)
if not integration and current_user.is_admin:
# Create global integration (admin only)
result = service.create_integration(provider, user_id=None, is_global=True)
if result["success"]:
integration = result["integration"]
else:
# Per-user integration
integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first()
# Handle POST (OAuth credential updates - admin only for global integrations)
if request.method == "POST":
if is_global and not current_user.is_admin:
flash(_("Only administrators can configure global integrations."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
# Check if this is an OAuth credential update (admin section)
if request.form.get("action") == "update_credentials":
# Update OAuth credentials in Settings
if provider == "trello":
# Trello uses API key + secret, not OAuth
api_key = request.form.get("trello_api_key", "").strip()
api_secret = request.form.get("trello_api_secret", "").strip()
if api_key:
settings.trello_api_key = api_key
if api_secret:
settings.trello_api_secret = api_secret
# Also save token if provided (for backward compatibility)
token = request.form.get("trello_token", "").strip()
if token and integration:
service.save_credentials(
integration_id=integration.id,
access_token=token,
refresh_token=None,
expires_at=None,
token_type="Bearer",
scope="read,write",
extra_data={"api_key": api_key},
)
else:
# OAuth-based integrations
client_id = request.form.get(f"{provider}_client_id", "").strip()
client_secret = request.form.get(f"{provider}_client_secret", "").strip()
# Map provider names to Settings attributes - support all known providers
attr_map = {
"jira": ("jira_client_id", "jira_client_secret"),
"slack": ("slack_client_id", "slack_client_secret"),
"github": ("github_client_id", "github_client_secret"),
"google_calendar": ("google_calendar_client_id", "google_calendar_client_secret"),
"outlook_calendar": ("outlook_calendar_client_id", "outlook_calendar_client_secret"),
"microsoft_teams": ("microsoft_teams_client_id", "microsoft_teams_client_secret"),
"asana": ("asana_client_id", "asana_client_secret"),
"gitlab": ("gitlab_client_id", "gitlab_client_secret"),
"quickbooks": ("quickbooks_client_id", "quickbooks_client_secret"),
"xero": ("xero_client_id", "xero_client_secret"),
}
if provider in attr_map:
id_attr, secret_attr = attr_map[provider]
if client_id:
try:
setattr(settings, id_attr, client_id)
except AttributeError:
logger.warning(f"Settings attribute {id_attr} does not exist, skipping")
if client_secret:
try:
setattr(settings, secret_attr, client_secret)
except AttributeError:
logger.warning(f"Settings attribute {secret_attr} does not exist, skipping")
else:
logger.warning(f"Provider {provider} not in attr_map, cannot save OAuth credentials")
# Handle special fields (save even if empty to allow clearing)
if provider == "outlook_calendar":
tenant_id = request.form.get("outlook_calendar_tenant_id", "").strip()
try:
# Allow empty value (will use "common" as default)
settings.outlook_calendar_tenant_id = tenant_id if tenant_id else ""
except AttributeError:
logger.warning("Settings attribute outlook_calendar_tenant_id does not exist, skipping")
elif provider == "microsoft_teams":
tenant_id = request.form.get("microsoft_teams_tenant_id", "").strip()
try:
# Allow empty value (will use "common" as default)
settings.microsoft_teams_tenant_id = tenant_id if tenant_id else ""
except AttributeError:
logger.warning("Settings attribute microsoft_teams_tenant_id does not exist, skipping")
elif provider == "gitlab":
instance_url = request.form.get("gitlab_instance_url", "").strip()
if instance_url:
try:
settings.gitlab_instance_url = instance_url
except AttributeError:
logger.warning("Settings attribute gitlab_instance_url does not exist, skipping")
else:
# Set default if empty
try:
if not settings.gitlab_instance_url:
settings.gitlab_instance_url = "https://gitlab.com"
except AttributeError:
pass
if safe_commit("update_integration_credentials", {"provider": provider}):
flash(_("Integration credentials updated successfully."), "success")
if provider == "google_calendar":
flash(
_("Users can now connect their Google Calendar. They will be automatically redirected to Google for authorization."),
"info",
)
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Failed to update credentials."), "error")
# Check if this is an integration config update
elif request.form.get("action") == "update_config":
# Get the integration to update
integration_to_update = integration if integration else user_integration
if not integration_to_update:
flash(_("Integration not found. Please connect the integration first."), "error")
return redirect(url_for("integrations.manage_integration", provider=provider))
# Get config schema from connector
config_schema = {}
if connector_class and hasattr(connector_class, "get_config_schema"):
try:
# Need a temporary instance to call get_config_schema
temp_connector = connector_class(integration_to_update, None)
config_schema = temp_connector.get_config_schema()
except Exception as e:
logger.warning(f"Could not get config schema for {provider}: {e}")
# Update config from form
if not integration_to_update.config:
integration_to_update.config = {}
# Process config fields from schema
if config_schema and "fields" in config_schema:
for field in config_schema["fields"]:
field_name = field.get("name")
if not field_name:
continue
field_type = field.get("type", "string")
if field_type == "boolean":
# Checkboxes: present = True, absent = False
value = field_name in request.form
elif field_type == "json":
# JSON fields - parse if provided
value_str = request.form.get(field_name, "").strip()
if value_str:
try:
import json
value = json.loads(value_str)
except json.JSONDecodeError:
flash(_("Invalid JSON for field %(field)s", field=field.get("label", field_name)), "error")
continue
else:
value = None
else:
# String/number/url fields
value = request.form.get(field_name, "").strip()
if not value and field.get("required", False):
flash(_("Field %(field)s is required", field=field.get("label", field_name)), "error")
continue
# Only update if value is provided or it's a boolean (always set)
if value is not None and value != "":
integration_to_update.config[field_name] = value
elif field_type == "boolean":
# Always set boolean fields
integration_to_update.config[field_name] = value
# Ensure config is marked as modified
from sqlalchemy.orm.attributes import flag_modified
flag_modified(integration_to_update, "config")
if safe_commit("update_integration_config", {"integration_id": integration_to_update.id}):
flash(_("Integration configuration updated successfully."), "success")
return redirect(url_for("integrations.manage_integration", provider=provider))
else:
flash(_("Failed to update configuration."), "error")
# Get current credentials for display (always get from Settings model, not .env)
# This ensures we're showing what's in the database - Settings model prioritizes DB over .env
current_creds = {}
if current_user.is_admin:
current_creds = settings.get_integration_credentials(provider)
# For Trello, ensure api_secret is included (it should already be in the dict)
if provider == "trello" and "api_secret" not in current_creds:
if hasattr(settings, "trello_api_secret"):
current_creds["api_secret"] = settings.trello_api_secret or ""
# Get user's existing integration for this provider (if per-user)
user_integration = None
if not is_global:
user_integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first()
# Get connector if integration exists
connector = None
connector_error = None
if integration or user_integration:
integration_to_check = integration if integration else user_integration
try:
connector = service.get_connector(integration_to_check)
except Exception as e:
logger.error(f"Error initializing connector for integration: {e}", exc_info=True)
connector_error = str(e)
credentials = None
if integration:
credentials = IntegrationCredential.query.filter_by(integration_id=integration.id).first()
elif user_integration:
credentials = IntegrationCredential.query.filter_by(integration_id=user_integration.id).first()
# Get display info from connector class or use defaults
display_name = getattr(connector_class, "display_name", None) or provider.replace("_", " ").title()
description = getattr(connector_class, "description", None) or ""
return render_template(
"integrations/manage.html",
provider=provider,
connector_class=connector_class,
connector=connector,
connector_error=connector_error,
integration=integration,
user_integration=user_integration,
credentials=credentials,
current_creds=current_creds,
display_name=display_name,
description=description,
is_global=is_global,
)
@integrations_bp.route("/integrations/<int:integration_id>")
@login_required
def view_integration(integration_id):
@@ -304,12 +576,40 @@ def sync_integration(integration_id):
try:
sync_result = connector.sync_data()
# Update integration status
from datetime import datetime
integration.last_sync_at = datetime.utcnow()
if sync_result.get("success"):
flash(_("Sync completed successfully."), "success")
integration.last_sync_status = "success"
integration.last_error = None
message = sync_result.get("message", "Sync completed successfully.")
if sync_result.get("synced_count"):
message += f" Synced {sync_result['synced_count']} items."
flash(_("Sync completed successfully. %(details)s", details=message), "success")
else:
integration.last_sync_status = "error"
integration.last_error = sync_result.get("message", "Unknown error")
flash(_("Sync failed: %(message)s", message=sync_result.get("message", "Unknown error")), "error")
# Log sync event
service._log_event(
integration_id,
"sync",
sync_result.get("success", False),
sync_result.get("message"),
{"synced_count": sync_result.get("synced_count")} if sync_result.get("success") and sync_result.get("synced_count") else None
)
if not safe_commit("update_integration_sync_status", {"integration_id": integration_id}):
logger.warning(f"Could not update sync status for integration {integration_id}")
except Exception as e:
logger.error(f"Error syncing integration {integration_id}: {e}")
logger.error(f"Error syncing integration {integration_id}: {e}", exc_info=True)
integration.last_sync_status = "error"
integration.last_error = str(e)
from datetime import datetime
integration.last_sync_at = datetime.utcnow()
safe_commit("update_integration_sync_status_error", {"integration_id": integration_id})
flash(_("Error during sync: %(error)s", error=str(e)), "error")
return redirect(url_for("integrations.view_integration", integration_id=integration_id))
+11
View File
@@ -262,6 +262,17 @@ class IntegrationService:
)
db.session.add(event)
safe_commit("log_integration_event", {"integration_id": integration_id})
def update_integration_active_status(self, integration_id: int):
"""Update integration is_active status based on credentials."""
integration = Integration.query.get(integration_id)
if not integration:
return
has_credentials = IntegrationCredential.query.filter_by(integration_id=integration_id).first() is not None
if integration.is_active != has_credentials:
integration.is_active = has_credentials
safe_commit("update_integration_active_status", {"integration_id": integration_id})
@classmethod
def get_available_providers(cls) -> List[Dict[str, Any]]:
+1 -1
View File
@@ -43,7 +43,7 @@
<div>Webhooks</div>
<div class="text-xs mt-1 opacity-90">Outgoing Events</div>
</a>
<a href="{{ url_for('admin.list_integrations_admin') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
<a href="{{ url_for('integrations.list_integrations') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
<i class="fas fa-plug mb-2"></i>
<div>Integrations</div>
<div class="text-xs mt-1 opacity-90">OAuth Setup</div>
+2 -22
View File
@@ -222,12 +222,11 @@
{% set crm_open = ep.startswith('clients.') or ep.startswith('quotes.') or ep.startswith('contacts.') or ep.startswith('deals.') or ep.startswith('leads.') %}
{% set inventory_open = ep.startswith('inventory.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') or ep.startswith('integrations.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') or ep.startswith('integrations.') or ep == 'integrations.list_integrations' or ep == 'integrations.manage_integration' or ep == 'integrations.view_integration' or ep == 'integrations.connect_integration' or ep == 'integrations.caldav_setup' %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') or ep.startswith('audit_logs.') or ep.startswith('webhooks.') or ep.startswith('custom_field_definitions.') or ep.startswith('link_templates.') %}
{% set admin_user_mgmt_open = ep == 'admin.list_users' or ep.startswith('permissions.') %}
{% set admin_settings_open = ep == 'admin.settings' or ep == 'admin.email_support' or ep.startswith('admin.') and ('email_template' in ep or 'email-templates' in request.path) or ep == 'admin.pdf_layout' or ep == 'admin.quote_pdf_layout' or ep == 'admin.oidc_debug' %}
{% set admin_security_open = ep == 'admin.api_tokens' or ep.startswith('webhooks.') or ep.startswith('audit_logs.') %}
{% set admin_integrations_open = ep == 'admin.list_integrations_admin' %}
{% set admin_data_open = ep == 'expense_categories.list_categories' or ep == 'per_diem.list_rates' or ep.startswith('time_entry_templates.') or ep.startswith('custom_field_definitions.') or ep.startswith('link_templates.') %}
{% set admin_maintenance_open = ep == 'admin.system_info' or ep == 'admin.backups_management' or ep == 'admin.telemetry_dashboard' %}
{% set pdf_open = ep == 'admin.pdf_layout' or ep == 'admin.quote_pdf_layout' %}
@@ -610,7 +609,7 @@
<ul id="toolsDropdown" class="{% if not tools_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_import_export = ep.startswith('import_export.') %}
{% set nav_active_filters = ep.startswith('saved_filters.') %}
{% set nav_active_integrations = ep.startswith('integrations.') %}
{% set nav_active_integrations = ep.startswith('integrations.') or ep == 'integrations.list_integrations' or ep == 'integrations.manage_integration' or ep == 'integrations.view_integration' or ep == 'integrations.connect_integration' or ep == 'integrations.caldav_setup' %}
{% if is_module_enabled('integrations') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_integrations %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('integrations.list_integrations') }}">
@@ -771,25 +770,6 @@
</ul>
</li>
{% endif %}
<!-- Integrations Submenu -->
{% if current_user.is_admin %}
<li>
<button onclick="toggleDropdown('adminIntegrationsDropdown', event)" data-dropdown="adminIntegrationsDropdown" class="w-full flex items-center px-2 py-1 rounded {% if admin_integrations_open %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-plug w-4 mr-2"></i>
<span class="flex-1 text-left">{{ _('Integrations') }}</span>
<i class="fas fa-chevron-down text-xs"></i>
</button>
<ul id="adminIntegrationsDropdown" class="{% if admin_integrations_open %}{% else %}hidden {% endif %}mt-2 space-y-2 ml-6">
<li>
<a onclick="event.stopPropagation();" class="block px-2 py-1 rounded {% if ep == 'admin.list_integrations_admin' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_integrations_admin') }}">
<i class="fas fa-plug w-4 mr-2"></i>{{ _('Integrations') }}
</a>
</li>
</ul>
</li>
{% endif %}
<!-- Data Management Submenu -->
{% if current_user.is_admin or has_permission('manage_settings') %}
<li>
+62 -87
View File
@@ -11,102 +11,77 @@
{{ page_header(
icon_class='fas fa-plug',
title_text='Integrations',
subtitle_text='Connect with third-party services',
subtitle_text='Connect with third-party services to extend functionality',
breadcrumbs=breadcrumbs
) }}
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('Connect your Time Tracker with external services. Configure integrations below to sync data, automate workflows, and enhance productivity.') }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-6">
{% for provider in available_providers %}
{% set existing_integration = integrations|selectattr('provider', 'equalto', provider.provider)|first if integrations else none %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow hover:shadow-md transition-shadow">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center">
<i class="fas fa-{{ provider.icon }} text-primary text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold">{{ provider.display_name }}</h3>
{% if provider.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ provider.description }}</p>
{% endif %}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow hover:shadow-lg transition-shadow overflow-hidden">
<div class="p-6">
<div class="flex items-start justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center flex-shrink-0">
<i class="fas fa-{{ provider.icon }} text-primary text-xl"></i>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark truncate">{{ provider.display_name }}</h3>
{% if provider.description %}
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1 line-clamp-2">{{ provider.description }}</p>
{% endif %}
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
{% if existing_integration %}
{% if existing_integration.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-medium">
<i class="fas fa-check-circle mr-1"></i>{{ _('Connected') }}
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-medium">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Not Connected') }}
</span>
{% endif %}
{% if existing_integration.is_global %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ _('Global') }}
</span>
{% endif %}
{% if existing_integration.last_sync_at %}
<span class="text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Synced') }} {{ existing_integration.last_sync_at.strftime('%m/%d') }}
</span>
{% endif %}
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200 font-medium">
<i class="fas fa-circle mr-1"></i>{{ _('Not Set Up') }}
</span>
{% endif %}
</div>
<a href="{{ url_for('integrations.manage_integration', provider=provider.provider) }}"
class="block w-full {% if existing_integration and existing_integration.is_active %}bg-gray-600 hover:bg-gray-700{% else %}bg-primary hover:bg-primary/90{% endif %} text-white px-4 py-2.5 rounded-lg transition-colors text-center font-medium">
{% if existing_integration %}
{% if existing_integration.is_active %}
<i class="fas fa-cog mr-2"></i>{{ _('Manage') }}
{% else %}
<i class="fas fa-link mr-2"></i>{{ _('Connect') }}
{% endif %}
{% else %}
<i class="fas fa-plus mr-2"></i>{{ _('Setup') }}
{% endif %}
</a>
</div>
{% if existing_integration %}
{% if existing_integration.is_active %}
<a href="{{ url_for('integrations.view_integration', integration_id=existing_integration.id) }}" class="block w-full bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 transition-colors text-center">
<i class="fas fa-eye mr-2"></i>{{ _('View Integration') }}
</a>
{% else %}
{% if provider.provider == 'trello' %}
<a href="{{ url_for('admin.integration_setup', provider=provider.provider) if current_user.is_admin else '#' }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center {% if not current_user.is_admin %}opacity-50 cursor-not-allowed{% endif %}">
<i class="fas fa-cog mr-2"></i>{{ _('Configure') }}
</a>
{% else %}
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-link mr-2"></i>{{ _('Reconnect') }}
</a>
{% endif %}
{% endif %}
{% else %}
{% if provider.provider == 'trello' %}
<a href="{{ url_for('admin.integration_setup', provider=provider.provider) if current_user.is_admin else '#' }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center {% if not current_user.is_admin %}opacity-50 cursor-not-allowed{% endif %}">
<i class="fas fa-cog mr-2"></i>{{ _('Setup') }}
</a>
{% elif provider.provider == 'google_calendar' %}
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fab fa-google mr-2"></i>{{ _('Connect Google Calendar') }}
<div class="text-xs mt-1 opacity-90">{{ _('Automatically redirects to Google') }}</div>
</a>
{% elif provider.provider == 'caldav_calendar' %}
<a href="{{ url_for('integrations.caldav_setup') }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-calendar mr-2"></i>{{ _('Setup CalDAV Calendar') }}
<div class="text-xs mt-1 opacity-90">{{ _('Connect to Zimbra or other CalDAV server') }}</div>
</a>
{% else %}
<a href="{{ url_for('integrations.connect_integration', provider=provider.provider) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-plus mr-2"></i>{{ _('Connect') }}
</a>
{% endif %}
{% endif %}
</div>
{% endfor %}
</div>
{% if integrations %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Your Integrations') }}</h2>
<div class="space-y-4">
{% for integration in integrations %}
<div class="border border-border-light dark:border-border-dark rounded-lg p-4">
<div class="flex justify-between items-start">
<div class="flex-1">
<h3 class="font-semibold">{{ integration.name }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ integration.provider|title|replace('_', ' ') }}
{% if integration.is_global %}
<span class="ml-2 px-2 py-0.5 text-xs rounded bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ _('Global') }}</span>
{% endif %}
{% if integration.last_sync_at %}
• {{ _('Last synced') }}: {{ integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}
{% endif %}
</p>
</div>
<div class="flex items-center gap-2">
{% if integration.is_active %}
<span class="px-2 py-1 text-xs rounded bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">{{ _('Active') }}</span>
{% else %}
<span class="px-2 py-1 text-xs rounded bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ _('Inactive') }}</span>
{% endif %}
<a href="{{ url_for('integrations.view_integration', integration_id=integration.id) }}" class="text-primary hover:text-primary/80">
<i class="fas fa-eye"></i>
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% endblock %}
+445
View File
@@ -0,0 +1,445 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ display_name }} {{ _('Integration') }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Integrations', 'url': url_for('integrations.list_integrations')},
{'text': display_name}
] %}
{{ page_header(
icon_class='fas fa-plug',
title_text=display_name,
subtitle_text=description,
breadcrumbs=breadcrumbs
) }}
<div class="space-y-6">
<!-- OAuth Credentials Setup Section (Admin only) -->
{% if current_user.is_admin %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">
<i class="fas fa-key mr-2"></i>{{ _('OAuth Credentials Setup') }}
</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="update_credentials">
{% if provider == 'trello' %}
<!-- Trello API Key Setup -->
<div class="space-y-4">
<div>
<label for="trello_api_key" class="block text-sm font-medium mb-2">
{{ _('Trello API Key') }} <span class="text-red-500">*</span>
</label>
<input type="text"
name="trello_api_key"
id="trello_api_key"
value="{{ current_creds.get('api_key', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Get from https://trello.com/app-key') }}"
required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Get your API key from') }} <a href="https://trello.com/app-key" target="_blank" class="text-primary hover:underline">trello.com/app-key</a>
</p>
</div>
<div>
<label for="trello_api_secret" class="block text-sm font-medium mb-2">
{{ _('Trello API Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password"
name="trello_api_secret"
id="trello_api_secret"
value="{{ current_creds.get('api_secret', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Leave empty to keep current value') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Get your API secret from') }} <a href="https://trello.com/app-key" target="_blank" class="text-primary hover:underline">trello.com/app-key</a>
{{ _('(shown after generating API key)') }}
</p>
</div>
<div class="p-3 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('After saving API Key and Secret, you can connect Trello using OAuth flow.') }}
</p>
</div>
</div>
{% else %}
<!-- OAuth-based Integrations -->
<div class="space-y-4">
<div>
<label for="{{ provider }}_client_id" class="block text-sm font-medium mb-2">
{{ _('OAuth Client ID') }}
</label>
<input type="text"
name="{{ provider }}_client_id"
id="{{ provider }}_client_id"
value="{{ current_creds.get('client_id', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('OAuth Client ID') }}">
</div>
<div>
<label for="{{ provider }}_client_secret" class="block text-sm font-medium mb-2">
{{ _('OAuth Client Secret') }}
</label>
<input type="password"
name="{{ provider }}_client_secret"
id="{{ provider }}_client_secret"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Leave empty to keep current value') }}">
</div>
{% if provider in ['outlook_calendar', 'microsoft_teams'] %}
<div>
<label for="{{ provider }}_tenant_id" class="block text-sm font-medium mb-2">
{{ _('Tenant ID') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
</label>
<input type="text"
name="{{ provider }}_tenant_id"
id="{{ provider }}_tenant_id"
value="{{ current_creds.get('tenant_id', '') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ _('Use "common" for multi-tenant, or leave empty for common') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Leave empty to use "common" (multi-tenant). Enter your Azure AD tenant ID for single-tenant apps.') }}
</p>
</div>
{% endif %}
{% if provider == 'gitlab' %}
<div>
<label for="gitlab_instance_url" class="block text-sm font-medium mb-2">
{{ _('GitLab Instance URL') }}
</label>
<input type="url"
name="gitlab_instance_url"
id="gitlab_instance_url"
value="{{ current_creds.get('instance_url', 'https://gitlab.com') }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="https://gitlab.com"
required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('URL of your GitLab instance. Use "https://gitlab.com" for GitLab.com or your self-hosted GitLab URL.') }}
</p>
</div>
{% endif %}
</div>
{% if provider != 'trello' %}
<div class="mt-6 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg">
<h3 class="font-semibold mb-2">{{ _('OAuth Redirect URI') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
{{ _('Add this URL as an authorized redirect URI in your OAuth app settings:') }}
</p>
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
{{ url_for('integrations.oauth_callback', provider=provider, _external=True) }}
</code>
{% if provider == 'google_calendar' %}
<div class="mt-4 p-3 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded">
<p class="text-sm text-green-800 dark:text-green-200 font-semibold mb-2">
<i class="fas fa-magic mr-2"></i>{{ _('Automatic Connection Flow') }}
</p>
<ul class="text-sm text-green-700 dark:text-green-300 space-y-1 list-disc list-inside">
<li>{{ _('After you save these credentials, users can click "Connect Google Calendar"') }}</li>
<li>{{ _('They will be automatically redirected to Google OAuth') }}</li>
<li>{{ _('No manual credential entry needed - fully automatic!') }}</li>
<li>{{ _('Each user connects their own Google Calendar account') }}</li>
</ul>
</div>
{% endif %}
</div>
{% endif %}
{% endif %}
<div class="mt-6 flex gap-4">
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ _('Save Credentials') }}
</button>
</div>
</form>
</div>
{% endif %}
<!-- Integration Configuration Section (if connector provides config schema) -->
{% if active_integration and config_schema and config_schema.get('fields') %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">
<i class="fas fa-cog mr-2"></i>{{ _('Integration Configuration') }}
</h2>
<form method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="action" value="update_config">
<div class="space-y-4">
{% for field in config_schema.fields %}
{% set field_name = field.name %}
{% set field_type = field.get('type', 'string') %}
{% set field_value = current_config.get(field_name, field.get('default', '')) %}
<div>
<label for="config_{{ field_name }}" class="block text-sm font-medium mb-2">
{{ field.get('label', field_name) }}
{% if field.get('required', False) %}
<span class="text-red-500">*</span>
{% endif %}
</label>
{% if field_type == 'boolean' %}
<div class="flex items-center">
<input type="checkbox"
name="{{ field_name }}"
id="config_{{ field_name }}"
{% if field_value %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="config_{{ field_name }}" class="ml-2 text-sm text-text-muted-light dark:text-text-muted-dark">
{{ field.get('description', '') }}
</label>
</div>
{% elif field_type == 'text' or field_type == 'textarea' %}
<textarea
name="{{ field_name }}"
id="config_{{ field_name }}"
rows="{% if field_type == 'textarea' %}4{% else %}2{% endif %}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>{{ field_value }}</textarea>
{% elif field_type == 'json' %}
<textarea
name="{{ field_name }}"
id="config_{{ field_name }}"
rows="4"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark font-mono text-sm"
placeholder='{{ field.get('placeholder', '{}') }}'
{% if field.get('required', False) %}required{% endif %}>{% if field_value %}{{ field_value|tojson }}{% endif %}</textarea>
{% else %}
<input type="{{ field_type }}"
name="{{ field_name }}"
id="config_{{ field_name }}"
value="{{ field_value }}"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark"
placeholder="{{ field.get('placeholder', '') }}"
{% if field.get('required', False) %}required{% endif %}>
{% endif %}
{% if field.get('help') or field.get('description') %}
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ field.get('help', field.get('description', '')) }}
</p>
{% endif %}
</div>
{% endfor %}
</div>
<div class="mt-6">
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ _('Save Configuration') }}
</button>
</div>
</form>
</div>
{% endif %}
<!-- Connection Management Section -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">
<i class="fas fa-link mr-2"></i>{{ _('Connection Management') }}
</h2>
{% set active_integration = integration if is_global else user_integration %}
{% if connector_error %}
<div class="mb-4 p-3 bg-red-100 dark:bg-red-900/20 border border-red-300 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200">
<i class="fas fa-exclamation-triangle mr-2"></i>
{{ _('Connector Error') }}: {{ connector_error }}
</p>
</div>
{% endif %}
{% if active_integration %}
<!-- Integration exists - show status and actions -->
<div class="space-y-4">
<div class="p-4 bg-background-light dark:bg-background-dark rounded-lg">
<div class="flex items-center gap-2 mb-3">
<h3 class="font-semibold text-lg">{{ display_name }}</h3>
{% if active_integration.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-medium">
<i class="fas fa-check-circle mr-1"></i>{{ _('Connected') }}
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-medium">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Not Connected') }}
</span>
{% endif %}
{% if is_global %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
{{ _('Global') }}
</span>
{% endif %}
{% if active_integration.last_sync_status == 'success' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
<i class="fas fa-check mr-1"></i>{{ _('Sync OK') }}
</span>
{% elif active_integration.last_sync_status == 'error' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Sync Error') }}
</span>
{% endif %}
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 text-sm">
{% if credentials %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Connection Status') }}:</span>
<span class="text-text-muted-light dark:text-text-muted-dark ml-2">
{% if credentials.expires_at %}
{{ _('Expires') }}: {{ credentials.expires_at.strftime('%Y-%m-%d %H:%M') }}
{% else %}
{{ _('No expiration') }}
{% endif %}
</span>
</div>
{% else %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Connection Status') }}:</span>
<span class="text-yellow-600 dark:text-yellow-400 ml-2">{{ _('Not connected') }}</span>
</div>
{% endif %}
{% if active_integration.last_sync_at %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Last Sync') }}:</span>
<span class="text-text-muted-light dark:text-text-muted-dark ml-2">
{{ active_integration.last_sync_at.strftime('%Y-%m-%d %H:%M') }}
</span>
</div>
{% else %}
<div>
<span class="font-medium text-text-light dark:text-text-dark">{{ _('Last Sync') }}:</span>
<span class="text-text-muted-light dark:text-text-muted-dark ml-2">{{ _('Never') }}</span>
</div>
{% endif %}
</div>
{% if active_integration.last_error %}
<div class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200">
<i class="fas fa-exclamation-triangle mr-2"></i>
<strong>{{ _('Last Error') }}:</strong> {{ active_integration.last_error }}
</p>
</div>
{% endif %}
</div>
{% if connector %}
<div class="flex gap-2">
<form method="POST" action="{{ url_for('integrations.test_integration', integration_id=active_integration.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-vial mr-2"></i>{{ _('Test Connection') }}
</button>
</form>
<form method="POST" action="{{ url_for('integrations.sync_integration', integration_id=active_integration.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-sync mr-2"></i>{{ _('Sync Now') }}
</button>
</form>
<a href="{{ url_for('integrations.view_integration', integration_id=active_integration.id) }}" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors inline-block">
<i class="fas fa-eye mr-2"></i>{{ _('View Details') }}
</a>
</div>
{% endif %}
</div>
{% elif provider == 'caldav_calendar' %}
<!-- CalDAV setup -->
<div class="space-y-4">
<p class="text-text-muted-light dark:text-text-muted-dark">
{{ _('CalDAV uses username/password authentication. Click below to set up your CalDAV connection.') }}
</p>
<a href="{{ url_for('integrations.caldav_setup') }}" class="inline-block bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-cog mr-2"></i>{{ _('Setup CalDAV Integration') }}
</a>
</div>
{% elif provider == 'trello' %}
<!-- Trello - requires admin setup first -->
{% if is_global and not current_user.is_admin %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Trello integration must be configured by an administrator.') }}
</p>
</div>
{% elif current_creds.get('api_key') %}
<div class="space-y-4">
<p class="text-text-muted-light dark:text-text-muted-dark">
{{ _('OAuth credentials are configured. You can now connect Trello.') }}
</p>
<a href="{{ url_for('integrations.connect_integration', provider=provider) }}" class="inline-block bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-link mr-2"></i>{{ _('Connect Trello') }}
</a>
</div>
{% else %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Please configure Trello API key and token in the OAuth Credentials Setup section above.') }}
</p>
</div>
{% endif %}
{% else %}
<!-- OAuth-based integrations - show connect button if credentials are configured -->
{% if current_creds.get('client_id') or (is_global and current_user.is_admin) or (not is_global) %}
<div class="space-y-4">
{% if is_global and not current_creds.get('client_id') and current_user.is_admin %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Please configure OAuth credentials in the section above before connecting.') }}
</p>
</div>
{% elif not is_global and not current_creds.get('client_id') %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('OAuth credentials need to be configured by an administrator first.') }}
</p>
</div>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark">
{% if provider == 'google_calendar' %}
{{ _('Connect your Google Calendar account. You will be redirected to Google for authorization.') }}
{% else %}
{{ _('Connect this integration. You will be redirected to the provider for authorization.') }}
{% endif %}
</p>
<a href="{{ url_for('integrations.connect_integration', provider=provider) }}" class="inline-block bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-link mr-2"></i>
{% if provider == 'google_calendar' %}
{{ _('Connect Google Calendar') }}
{% else %}
{{ _('Connect') }} {{ display_name }}
{% endif %}
</a>
{% endif %}
</div>
{% else %}
<div class="p-4 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<p class="text-sm text-yellow-800 dark:text-yellow-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('OAuth credentials need to be configured by an administrator first.') }}
</p>
</div>
{% endif %}
{% endif %}
</div>
</div>
{% endblock %}