mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-04 19:40:04 -05:00
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:
@@ -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
@@ -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))
|
||||
|
||||
@@ -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]]:
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user