feat: add integration setup wizards for all providers

- Add setup wizard system for guided integration configuration
- Create wizard templates for all integration providers:
  * Asana, GitHub, GitLab, Jira, Microsoft Teams
  * Outlook Calendar, QuickBooks, Trello, Xero
- Add wizard_base.html template with common wizard functionality
- Implement setup_wizard route with provider detection
- Update integration list and manage pages with wizard links
- Add has_setup_wizard() helper to check wizard availability
- Create integration_wizard.js for wizard JavaScript functionality
- Improve UX with step-by-step guided setup process
This commit is contained in:
Dries Peeters
2026-01-06 21:51:22 +01:00
parent c844e97741
commit 7322f0e42e
14 changed files with 2657 additions and 11 deletions
+264
View File
@@ -11,6 +11,7 @@ from app.services.integration_service import IntegrationService
from app.utils.db import safe_commit
import secrets
import logging
import os
# Import registry to ensure connectors are registered
try:
@@ -23,6 +24,15 @@ logger = logging.getLogger(__name__)
integrations_bp = Blueprint("integrations", __name__)
def has_setup_wizard(provider):
"""Check if a setup wizard template exists for the given provider."""
from flask import current_app
template_path = f"integrations/wizard_{provider}.html"
template_dir = os.path.join(current_app.root_path, "templates")
template_file = os.path.join(template_dir, template_path)
return os.path.exists(template_file)
@integrations_bp.route("/integrations")
@login_required
def list_integrations():
@@ -31,11 +41,15 @@ def list_integrations():
integrations = service.list_integrations(current_user.id)
available_providers = service.get_available_providers()
from flask import current_app
return render_template(
"integrations/list.html",
integrations=integrations,
available_providers=available_providers,
current_user=current_user,
config=current_app.config,
has_setup_wizard=has_setup_wizard,
)
@@ -614,6 +628,7 @@ def manage_integration(provider):
is_global=is_global,
config_schema=config_schema,
current_config=current_config,
has_setup_wizard=has_setup_wizard,
)
@@ -981,3 +996,252 @@ def integration_webhook(provider):
return jsonify({"success": True, "results": results}), 200
else:
return jsonify({"success": False, "results": results}), 500
@integrations_bp.route("/integrations/<provider>/wizard", methods=["GET", "POST"])
@login_required
def setup_wizard(provider):
"""Setup wizard for integration configuration."""
# Check if wizard exists
if not has_setup_wizard(provider):
flash(_("Setup wizard not available for this integration."), "error")
return redirect(url_for("integrations.list_integrations"))
service = IntegrationService()
# Check if provider is available
if provider not in service._connector_registry:
flash(_("Integration provider not available."), "error")
return redirect(url_for("integrations.list_integrations"))
# Get connector class
connector_class = service._connector_registry.get(provider)
if not connector_class:
flash(_("Connector class not found."), "error")
return redirect(url_for("integrations.list_integrations"))
# Get display info
display_name = getattr(connector_class, "display_name", None) or provider.replace("_", " ").title()
description = getattr(connector_class, "description", None) or ""
# 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:
result = service.create_integration(provider, user_id=None, is_global=True)
if result["success"]:
integration = result["integration"]
else:
integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first()
# Check permissions
if is_global and not current_user.is_admin:
flash(_("Only administrators can configure global integrations."), "error")
return redirect(url_for("integrations.list_integrations"))
# Handle POST - save wizard data
if request.method == "POST":
wizard_step = int(request.form.get("wizard_step", 1))
# Get current config or create new
if integration:
if not integration.config:
integration.config = {}
current_config = integration.config
else:
current_config = {}
# Update config based on wizard step and form data
# This is a generic handler - specific wizards will override with their own logic
config_schema = {}
if connector_class and hasattr(connector_class, "get_config_schema"):
try:
temp_integration = integration if integration else Integration(provider=provider, config={})
temp_connector = connector_class(temp_integration, None)
config_schema = temp_connector.get_config_schema()
except Exception as e:
logger.warning(f"Could not get config schema for {provider}: {e}")
# Process form fields based on config 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":
value = field_name in request.form
elif field_type == "array":
values = request.form.getlist(field_name)
value = values if values else field.get("default", [])
elif field_type in ("select", "string", "url", "text", "password", "number"):
value = request.form.get(field_name, "").strip()
if not value:
value = field.get("default")
elif field_type == "json":
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:
value = request.form.get(field_name, "").strip()
if value is not None:
current_config[field_name] = value
# Save OAuth credentials if provided (admin only for global)
if is_global and current_user.is_admin:
from app.models import Settings
settings = Settings.get_settings()
client_id = request.form.get(f"{provider}_client_id", "").strip()
client_secret = request.form.get(f"{provider}_client_secret", "").strip()
if client_id:
attr_map = {
"jira": ("jira_client_id", "jira_client_id"),
"slack": ("slack_client_id", "slack_client_secret"),
"github": ("github_client_id", "github_client_secret"),
"gitlab": ("gitlab_client_id", "gitlab_client_secret"),
"quickbooks": ("quickbooks_client_id", "quickbooks_client_secret"),
"xero": ("xero_client_id", "xero_client_secret"),
"asana": ("asana_client_id", "asana_client_secret"),
"outlook_calendar": ("outlook_calendar_client_id", "outlook_calendar_client_secret"),
"microsoft_teams": ("microsoft_teams_client_id", "microsoft_teams_client_secret"),
}
if provider in attr_map:
id_attr, secret_attr = attr_map[provider]
if hasattr(settings, id_attr):
setattr(settings, id_attr, client_id)
if client_secret and hasattr(settings, secret_attr):
setattr(settings, secret_attr, client_secret)
# Create integration if it doesn't exist
if not integration:
result = service.create_integration(
provider,
user_id=None if is_global else current_user.id,
is_global=is_global
)
if result["success"]:
integration = result["integration"]
else:
flash(result["message"], "error")
return redirect(url_for("integrations.setup_wizard", provider=provider))
# Update integration config
integration.config = current_config
from sqlalchemy.orm.attributes import flag_modified
flag_modified(integration, "config")
# If this is the last step, save and redirect
# Individual wizard templates will handle determining the last step
if safe_commit("save_wizard_config", {"provider": provider}):
# Check if this was the final step (wizard template should set this)
if request.form.get("wizard_final_step") == "true":
flash(_("Integration configured successfully!"), "success")
return jsonify({
"success": True,
"redirect_url": url_for("integrations.manage_integration", provider=provider)
})
else:
return jsonify({"success": True})
else:
return jsonify({"success": False, "message": _("Failed to save configuration.")})
# GET - render wizard
current_config = integration.config if integration and integration.config else {}
config_schema = {}
if connector_class and hasattr(connector_class, "get_config_schema"):
try:
temp_integration = integration if integration else Integration(provider=provider, config={})
temp_connector = connector_class(temp_integration, None)
config_schema = temp_connector.get_config_schema()
except Exception as e:
logger.warning(f"Could not get config schema for {provider}: {e}")
# Determine step labels based on provider
step_labels_map = {
"jira": [_("OAuth Setup"), _("Connection Test"), _("Sync Config"), _("Advanced"), _("Review")],
"gitlab": [_("Instance"), _("OAuth"), _("Repositories"), _("Sync Settings"), _("Review")],
"quickbooks": [_("OAuth"), _("Company"), _("Sync Config"), _("Mappings"), _("Review")],
"xero": [_("OAuth"), _("Tenant"), _("Sync Config"), _("Mappings"), _("Review")],
"github": [_("OAuth"), _("Repositories"), _("Sync Config"), _("Webhooks"), _("Review")],
"asana": [_("OAuth"), _("Workspace"), _("Projects"), _("Sync Config"), _("Review")],
"trello": [_("API Keys"), _("Connection Test"), _("Review")],
"outlook_calendar": [_("Tenant ID"), _("OAuth"), _("Review")],
"microsoft_teams": [_("Tenant ID"), _("OAuth"), _("Review")],
}
step_labels = step_labels_map.get(provider, [])
total_steps = len(step_labels) if step_labels else 5 # Default to 5 if not specified
# Get test connection URL if available
test_connection_url = None
if provider in ["jira", "gitlab", "trello"]:
test_connection_url = url_for("integrations.test_connection_wizard", provider=provider)
wizard_title = _("%(name)s Setup Wizard", name=display_name)
wizard_subtitle = _("Guided step-by-step configuration for %(name)s", name=display_name)
return render_template(
f"integrations/wizard_{provider}.html",
provider=provider,
display_name=display_name,
description=description,
connector_class=connector_class,
integration=integration,
current_config=current_config,
config_schema=config_schema,
is_global=is_global,
wizard_title=wizard_title,
wizard_subtitle=wizard_subtitle,
wizard_save_url=url_for("integrations.setup_wizard", provider=provider),
total_steps=total_steps,
step_labels=step_labels,
test_connection_url=test_connection_url,
)
@integrations_bp.route("/integrations/<provider>/wizard/test-connection", methods=["POST"])
@login_required
def test_connection_wizard(provider):
"""Test connection from wizard."""
from flask import request as flask_request
service = IntegrationService()
# Get integration
is_global = provider not in ("google_calendar", "caldav_calendar")
if is_global:
integration = service.get_global_integration(provider)
else:
integration = Integration.query.filter_by(provider=provider, user_id=current_user.id, is_global=False).first()
if not integration:
return jsonify({"success": False, "error": _("Integration not found")}), 404
# Get connector
connector = service.get_connector(integration)
if not connector:
return jsonify({"success": False, "error": _("Connector not available")}), 400
# Test connection
try:
result = connector.test_connection()
return jsonify(result)
except Exception as e:
logger.error(f"Connection test error for {provider}: {e}", exc_info=True)
return jsonify({"success": False, "error": str(e)}), 500
+431
View File
@@ -0,0 +1,431 @@
/**
* Generic Integration Setup Wizard JavaScript
* Handles step navigation, validation, connection testing, and form submission
* Reusable across all integration setup wizards
*/
(function() {
'use strict';
/**
* IntegrationWizard class - handles multi-step wizard functionality
*/
class IntegrationWizard {
constructor(options) {
this.currentStep = 1;
this.totalSteps = options.totalSteps || 5;
this.provider = options.provider || '';
this.saveUrl = options.saveUrl || '';
this.testConnectionUrl = options.testConnectionUrl || null;
this.connectionTestResult = null;
this.onStepChangeCallbacks = [];
this.validationCallbacks = {};
this.options = options;
}
init() {
this.setupEventListeners();
this.updateStepUI();
// Call custom initialization if provided
if (typeof this.options.onInit === 'function') {
this.options.onInit.call(this);
}
}
setupEventListeners() {
const nextBtn = document.getElementById('next-btn');
const prevBtn = document.getElementById('prev-btn');
const form = document.getElementById('wizard-form');
if (nextBtn) {
nextBtn.addEventListener('click', () => this.handleNext());
}
if (prevBtn) {
prevBtn.addEventListener('click', () => this.handlePrevious());
}
if (form) {
form.addEventListener('submit', (e) => this.handleSubmit(e));
}
// Copy button support
document.addEventListener('click', (e) => {
if (e.target.closest('.copy-btn')) {
const btn = e.target.closest('.copy-btn');
const targetId = btn.getAttribute('data-target');
this.copyToClipboard(targetId, btn);
}
});
}
handleNext() {
if (this.validateCurrentStep()) {
if (this.currentStep < this.totalSteps) {
this.currentStep++;
this.updateStepUI();
} else {
// On last step, submit the form
this.submitForm();
}
}
}
handlePrevious() {
if (this.currentStep > 1) {
this.currentStep--;
this.updateStepUI();
}
}
validateCurrentStep() {
// Check if there's a custom validation callback for this step
if (this.validationCallbacks[this.currentStep]) {
return this.validationCallbacks[this.currentStep].call(this);
}
// Default validation: check required fields in current step
return this.validateStepFields();
}
validateStepFields() {
const stepElement = document.querySelector(`.wizard-step[data-step="${this.currentStep}"]`);
if (!stepElement) return true;
const requiredFields = stepElement.querySelectorAll('input[required], select[required], textarea[required]');
let isValid = true;
requiredFields.forEach(field => {
const value = field.value.trim();
if (!value) {
this.showError(field.id || field.name, 'This field is required');
isValid = false;
} else {
this.clearError(field.id || field.name);
// Validate URL fields
if (field.type === 'url') {
try {
new URL(value);
} catch (e) {
this.showError(field.id || field.name, 'Please enter a valid URL');
isValid = false;
}
}
// Validate email fields
if (field.type === 'email') {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(value)) {
this.showError(field.id || field.name, 'Please enter a valid email address');
isValid = false;
}
}
}
});
return isValid;
}
validateStep(stepNumber) {
this.currentStep = stepNumber;
return this.validateCurrentStep();
}
addValidationCallback(stepNumber, callback) {
this.validationCallbacks[stepNumber] = callback;
}
onStepChange(callback) {
this.onStepChangeCallbacks.push(callback);
}
updateStepUI() {
// Hide all steps
document.querySelectorAll('.wizard-step').forEach(step => {
step.classList.add('hidden');
});
// Show current step
const currentStepEl = document.querySelector(`.wizard-step[data-step="${this.currentStep}"]`);
if (currentStepEl) {
currentStepEl.classList.remove('hidden');
}
// Update progress indicators
document.querySelectorAll('.step-indicator').forEach((indicator) => {
const stepNum = parseInt(indicator.getAttribute('data-step'));
if (stepNum < this.currentStep) {
// Completed step
indicator.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
indicator.classList.add('bg-green-500', 'text-white');
} else if (stepNum === this.currentStep) {
// Current step
indicator.classList.remove('bg-gray-200', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
indicator.classList.add('bg-primary', 'text-white');
} else {
// Future step
indicator.classList.remove('bg-primary', 'bg-green-500', 'text-white');
indicator.classList.add('bg-gray-200', 'dark:bg-gray-700', 'text-gray-600', 'dark:text-gray-400');
}
});
// Update connectors
document.querySelectorAll('.step-connector').forEach((connector) => {
const stepNum = parseInt(connector.getAttribute('data-step'));
if (stepNum < this.currentStep) {
connector.classList.remove('bg-gray-200', 'dark:bg-gray-700');
connector.classList.add('bg-green-500');
} else {
connector.classList.remove('bg-green-500');
connector.classList.add('bg-gray-200', 'dark:bg-gray-700');
}
});
// Update navigation buttons
const prevBtn = document.getElementById('prev-btn');
const nextBtn = document.getElementById('next-btn');
if (prevBtn) {
prevBtn.classList.toggle('hidden', this.currentStep === 1);
}
if (nextBtn) {
if (this.currentStep === this.totalSteps) {
nextBtn.innerHTML = '<i class="fas fa-check mr-2"></i>' + (this.options.finishText || 'Finish');
} else {
nextBtn.innerHTML = (this.options.nextText || 'Next') + '<i class="fas fa-arrow-right ml-2"></i>';
}
}
// Update hidden step input
const stepInput = document.getElementById('wizard-step-input');
if (stepInput) {
stepInput.value = this.currentStep;
}
// Call step change callbacks
this.onStepChangeCallbacks.forEach(callback => {
callback.call(this, this.currentStep);
});
// Call custom step handler if provided
if (this.options.onStepChange) {
this.options.onStepChange.call(this, this.currentStep);
}
}
async testConnection(data) {
if (!this.testConnectionUrl) {
console.warn('Test connection URL not configured');
return null;
}
const btn = document.getElementById('test-connection-btn');
if (btn) {
const originalText = btn.innerHTML;
btn.disabled = true;
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Testing...';
try {
const response = await fetch(this.testConnectionUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': this.getCSRFToken()
},
body: JSON.stringify(data)
});
const result = await response.json();
this.connectionTestResult = result;
return result;
} catch (error) {
console.error('Connection test error:', error);
return {
success: false,
error: 'Network error: ' + error.message
};
} finally {
btn.disabled = false;
btn.innerHTML = originalText;
}
}
return null;
}
displayConnectionResults(result, resultsContainerId = 'connection-test-results') {
const resultsDiv = document.getElementById(resultsContainerId);
if (!resultsDiv) return;
if (result.success) {
resultsDiv.innerHTML = `
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg mb-4">
<p class="text-sm text-green-800 dark:text-green-200">
<i class="fas fa-check-circle mr-2"></i>
Connection test successful!
</p>
</div>
`;
} else {
let errorDetails = '';
if (result.error) {
errorDetails = `<p class="mt-2 text-xs">${this.escapeHtml(result.error)}</p>`;
}
resultsDiv.innerHTML = `
<div class="p-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg mb-4">
<p class="text-sm text-red-800 dark:text-red-200">
<i class="fas fa-exclamation-triangle mr-2"></i>
Connection test failed.
</p>
${errorDetails}
</div>
`;
}
}
showError(fieldId, message) {
const field = document.getElementById(fieldId);
if (field) {
field.classList.add('border-red-500');
let errorDiv = field.parentElement.querySelector('.error-message');
if (!errorDiv) {
errorDiv = document.createElement('p');
errorDiv.className = 'error-message text-red-500 text-xs mt-1';
field.parentElement.appendChild(errorDiv);
}
errorDiv.textContent = message;
}
}
clearError(fieldId) {
const field = document.getElementById(fieldId);
if (field) {
field.classList.remove('border-red-500');
const errorDiv = field.parentElement.querySelector('.error-message');
if (errorDiv) {
errorDiv.remove();
}
}
}
clearAllErrors() {
document.querySelectorAll('.error-message').forEach(el => el.remove());
document.querySelectorAll('.border-red-500').forEach(el => el.classList.remove('border-red-500'));
}
async submitForm() {
const form = document.getElementById('wizard-form');
if (!form) return;
// Collect all form data
const formData = new FormData(form);
formData.append('wizard_step', this.currentStep);
try {
const response = await fetch(this.saveUrl, {
method: 'POST',
headers: {
'X-CSRFToken': this.getCSRFToken()
},
body: formData
});
const result = await response.json();
if (result.success) {
if (result.redirect_url) {
window.location.href = result.redirect_url;
} else {
// Show success message and redirect to manage page
window.location.href = `/integrations/${this.provider}/manage`;
}
} else {
alert(result.message || 'Failed to save configuration. Please try again.');
}
} catch (error) {
console.error('Form submission error:', error);
alert('An error occurred while saving. Please try again.');
}
}
handleSubmit(e) {
e.preventDefault();
if (this.currentStep === this.totalSteps) {
this.submitForm();
} else {
this.handleNext();
}
}
copyToClipboard(elementId, button) {
const element = document.getElementById(elementId);
if (!element) return;
const text = element.textContent || element.value;
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-check mr-1"></i>Copied!';
button.classList.add('bg-green-500');
setTimeout(() => {
button.innerHTML = originalText;
button.classList.remove('bg-green-500');
}, 2000);
}).catch(err => {
console.error('Failed to copy:', err);
alert('Failed to copy to clipboard');
});
} else {
// Fallback for older browsers
const textarea = document.createElement('textarea');
textarea.value = text;
textarea.style.position = 'fixed';
textarea.style.opacity = '0';
document.body.appendChild(textarea);
textarea.select();
try {
document.execCommand('copy');
const originalText = button.innerHTML;
button.innerHTML = '<i class="fas fa-check mr-1"></i>Copied!';
setTimeout(() => {
button.innerHTML = originalText;
}, 2000);
} catch (err) {
alert('Failed to copy to clipboard');
}
document.body.removeChild(textarea);
}
}
getCSRFToken() {
const tokenElement = document.querySelector('meta[name="csrf-token"]');
return tokenElement ? tokenElement.getAttribute('content') : '';
}
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Public API methods
goToStep(stepNumber) {
if (stepNumber >= 1 && stepNumber <= this.totalSteps) {
this.currentStep = stepNumber;
this.updateStepUI();
}
}
getCurrentStep() {
return this.currentStep;
}
}
// Make IntegrationWizard available globally
window.IntegrationWizard = IntegrationWizard;
})();
+59 -11
View File
@@ -21,6 +21,46 @@
</p>
</div>
{% if current_user.is_admin or current_user.has_permission('manage_oidc') %}
<!-- OIDC Authentication Setup -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow hover:shadow-lg transition-shadow overflow-hidden mb-6 border-2 border-primary/20">
<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-shield-alt 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">{{ _('OIDC / Single Sign-On') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('Configure OpenID Connect authentication for Single Sign-On (SSO) with your identity provider') }}
</p>
</div>
</div>
</div>
<div class="flex items-center gap-2 mb-4">
{% set auth_method = config.get('AUTH_METHOD', 'local') or 'local' %}
{% set oidc_enabled = auth_method.lower() in ['oidc', 'both'] %}
{% if oidc_enabled %}
<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>{{ _('Enabled') }}
</span>
{% 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 Configured') }}
</span>
{% endif %}
</div>
<a href="{{ url_for('admin.oidc_setup_wizard') }}"
class="block w-full bg-primary hover:bg-primary/90 text-white px-4 py-2.5 rounded-lg transition-colors text-center font-medium">
<i class="fas fa-magic mr-2"></i>{{ _('Setup Wizard') }}
</a>
</div>
</div>
{% endif %}
<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 %}
@@ -68,18 +108,26 @@
{% 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') }}
<div class="space-y-2">
{% if has_setup_wizard(provider.provider) %}
<a href="{{ url_for('integrations.setup_wizard', provider=provider.provider) }}"
class="block w-full bg-primary hover:bg-primary/90 text-white px-4 py-2.5 rounded-lg transition-colors text-center font-medium">
<i class="fas fa-magic mr-2"></i>{{ _('Setup Wizard') }}
</a>
{% endif %}
</a>
<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 %}{% if has_setup_wizard(provider.provider) %}bg-gray-200 dark:bg-gray-700 hover:bg-gray-300 dark:hover:bg-gray-600{% else %}bg-primary hover:bg-primary/90{% endif %}{% 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>
</div>
</div>
{% endfor %}
+19
View File
@@ -17,6 +17,25 @@
) }}
<div class="space-y-6">
<!-- Setup Wizard Button -->
{% if has_setup_wizard(provider) %}
<div class="bg-card-light dark:bg-card-dark p-4 rounded-lg shadow border-2 border-primary/20">
<div class="flex items-center justify-between">
<div>
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-1">
<i class="fas fa-magic mr-2 text-primary"></i>{{ _('Guided Setup Wizard') }}
</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">
{{ _('Use our step-by-step wizard to configure this integration easily.') }}
</p>
</div>
<a href="{{ url_for('integrations.setup_wizard', provider=provider) }}"
class="bg-primary hover:bg-primary/90 text-white px-6 py-2.5 rounded-lg transition-colors font-medium whitespace-nowrap">
<i class="fas fa-magic mr-2"></i>{{ _('Launch Setup Wizard') }}
</a>
</div>
</div>
{% endif %}
<!-- OAuth Credentials Setup Section (Admin only, not for CalDAV) -->
{% if current_user.is_admin and provider != 'caldav_calendar' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
@@ -0,0 +1,206 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: OAuth Setup -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: OAuth Setup') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Create an Asana app in Settings → Apps → Manage Developer Apps.') }}
</p>
</div>
<div>
<label for="asana_client_id" class="block text-sm font-medium mb-2">
{{ _('Asana OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="asana_client_id" name="asana_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="{{ _('Enter OAuth Client ID') }}" required>
</div>
<div>
<label for="asana_client_secret" class="block text-sm font-medium mb-2">
{{ _('Asana OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="asana_client_secret" name="asana_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h3 class="font-semibold mb-2">{{ _('OAuth Redirect URI') }}</h3>
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
{{ url_for('integrations.oauth_callback', provider='asana', _external=True) }}
</code>
</div>
</div>
</div>
<!-- Step 2: Workspace Selection -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: Workspace Selection') }}</h2>
<div class="space-y-4">
<div>
<label for="workspace_gid" class="block text-sm font-medium mb-2">
{{ _('Workspace GID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="workspace_gid" name="workspace_gid"
value="{{ current_config.get('workspace_gid', '') }}"
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="1234567890" required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Find your workspace GID in Asana workspace settings or API') }}
</p>
</div>
</div>
</div>
<!-- Step 3: Project Selection -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Project Selection') }} <span class="text-text-muted-light dark:text-text-muted-dark text-sm font-normal">({{ _('optional') }})</span></h2>
<div class="space-y-4">
<div>
<label for="project_gids" class="block text-sm font-medium mb-2">
{{ _('Project GIDs') }}
</label>
<input type="text" id="project_gids" name="project_gids"
value="{{ current_config.get('project_gids', '') }}"
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="1234567890, 9876543210">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Comma-separated list of specific project GIDs to sync. Leave empty to sync all projects in the workspace.') }}
</p>
</div>
</div>
</div>
<!-- Step 4: Sync Configuration -->
<div class="wizard-step hidden" data-step="4">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 4: Sync Configuration') }}</h2>
<div class="space-y-4">
<div>
<label for="sync_direction" class="block text-sm font-medium mb-2">{{ _('Sync Direction') }}</label>
<select id="sync_direction" name="sync_direction"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="asana_to_timetracker" {% if current_config.get('sync_direction', 'asana_to_timetracker') == 'asana_to_timetracker' %}selected{% endif %}>
{{ _('Asana → TimeTracker (Import only)') }}
</option>
<option value="timetracker_to_asana" {% if current_config.get('sync_direction') == 'timetracker_to_asana' %}selected{% endif %}>
{{ _('TimeTracker → Asana (Export only)') }}
</option>
<option value="bidirectional" {% if current_config.get('sync_direction') == 'bidirectional' %}selected{% endif %}>
{{ _('Bidirectional (Two-way sync)') }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Items to Sync') }}</label>
<div class="space-y-2">
{% set sync_items = current_config.get('sync_items', ['projects', 'tasks']) %}
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="projects" id="sync_projects"
{% if 'projects' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_projects" class="ml-2 text-sm">{{ _('Projects') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="tasks" id="sync_tasks"
{% if 'tasks' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_tasks" class="ml-2 text-sm">{{ _('Tasks') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="subtasks" id="sync_subtasks"
{% if 'subtasks' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_subtasks" class="ml-2 text-sm">{{ _('Subtasks') }}</label>
</div>
</div>
</div>
<div>
<label for="sync_interval" class="block text-sm font-medium mb-2">{{ _('Sync Schedule') }}</label>
<select id="sync_interval" name="sync_interval"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="manual" {% if current_config.get('sync_interval', 'manual') == 'manual' %}selected{% endif %}>{{ _('Manual only') }}</option>
<option value="hourly" {% if current_config.get('sync_interval') == 'hourly' %}selected{% endif %}>{{ _('Every hour') }}</option>
<option value="daily" {% if current_config.get('sync_interval') == 'daily' %}selected{% endif %}>{{ _('Daily') }}</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" name="auto_sync" id="auto_sync" value="1"
{% if current_config.get('auto_sync', False) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="auto_sync" class="ml-2 text-sm">{{ _('Auto Sync') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_completed" id="sync_completed" value="1"
{% if current_config.get('sync_completed', False) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_completed" class="ml-2 text-sm">{{ _('Sync Completed Tasks') }}</label>
</div>
</div>
</div>
<!-- Step 5: Review -->
<div class="wizard-step hidden" data-step="5">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 5: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Workspace GID') }}:</span>
<span id="review-workspace-gid" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Sync Direction') }}:</span>
<span id="review-sync-direction" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Items to Sync') }}:</span>
<span id="review-sync-items" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 5,
provider: 'asana',
saveUrl: '{{ wizard_save_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 5) {
const workspaceGid = document.getElementById('workspace_gid')?.value || '{{ _("Not set") }}';
const syncDirection = document.getElementById('sync_direction')?.selectedOptions[0]?.text || '';
const syncItems = Array.from(document.querySelectorAll('input[name="sync_items"]:checked'))
.map(cb => cb.nextElementSibling.textContent).join(', ') || '{{ _("None") }}';
document.getElementById('review-workspace-gid').textContent = workspaceGid;
document.getElementById('review-sync-direction').textContent = syncDirection;
document.getElementById('review-sync-items').textContent = syncItems;
}
}
});
wizard.init();
}
});
</script>
{% endblock %}
@@ -0,0 +1,83 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ wizard_title }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Integrations'), 'url': url_for('integrations.list_integrations')},
{'text': display_name, 'url': url_for('integrations.manage_integration', provider=provider)},
{'text': _('Setup Wizard')}
] %}
{{ page_header(
icon_class='fas fa-magic',
title_text=wizard_title,
subtitle_text=wizard_subtitle,
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<!-- Progress Indicator -->
<div class="mb-8">
<div class="flex items-center justify-between mb-4">
{% for step in range(1, total_steps + 1) %}
<div class="flex items-center space-x-2">
<div class="w-8 h-8 rounded-full bg-gray-200 dark:bg-gray-700 text-gray-600 dark:text-gray-400 flex items-center justify-center text-sm font-semibold step-indicator" data-step="{{ step }}">{{ step }}</div>
<span class="text-sm font-medium step-label" data-step="{{ step }}">{{ step_labels[step - 1] if step_labels and step < step_labels|length + 1 else _('Step') }} {{ step }}</span>
</div>
{% if not loop.last %}
<div class="flex-1 h-1 mx-4 bg-gray-200 dark:bg-gray-700 step-connector" data-step="{{ step }}"></div>
{% endif %}
{% endfor %}
</div>
</div>
<form id="wizard-form" method="POST" action="{{ wizard_save_url }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="provider" value="{{ provider }}">
<input type="hidden" name="wizard_step" id="wizard-step-input" value="1">
{% block wizard_steps %}
<!-- Steps will be defined in child templates -->
{% endblock %}
<!-- Navigation Buttons -->
<div class="flex justify-between mt-8 pt-6 border-t border-border-light dark:border-border-dark">
<button type="button" id="prev-btn" class="bg-gray-200 dark:bg-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors hidden">
<i class="fas fa-arrow-left mr-2"></i>{{ _('Previous') }}
</button>
<div class="ml-auto flex gap-2">
<button type="button" id="next-btn" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
{{ _('Next') }}<i class="fas fa-arrow-right ml-2"></i>
</button>
<a href="{{ url_for('integrations.manage_integration', provider=provider) }}" class="bg-gray-200 dark:bg-gray-700 px-6 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors inline-block">
{{ _('Cancel') }}
</a>
</div>
</div>
</form>
</div>
<script src="{{ url_for('static', filename='js/integration_wizard.js') }}"></script>
{% block wizard_js %}
<!-- Provider-specific JavaScript will be included here -->
{% endblock %}
<script>
// Initialize wizard with configuration
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: {{ total_steps }},
provider: '{{ provider }}',
saveUrl: '{{ wizard_save_url }}',
{% if test_connection_url %}
testConnectionUrl: '{{ test_connection_url }}',
{% endif %}
});
wizard.init();
}
});
</script>
{% endblock %}
@@ -0,0 +1,240 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: OAuth Setup -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: OAuth Setup') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Create a GitHub OAuth App in Settings → Developer settings → OAuth Apps.') }}
</p>
</div>
<div>
<label for="github_client_id" class="block text-sm font-medium mb-2">
{{ _('GitHub OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="github_client_id" name="github_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="{{ _('Enter OAuth Client ID') }}" required>
</div>
<div>
<label for="github_client_secret" class="block text-sm font-medium mb-2">
{{ _('GitHub OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="github_client_secret" name="github_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h3 class="font-semibold mb-2">{{ _('OAuth Redirect URI') }}</h3>
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
{{ url_for('integrations.oauth_callback', provider='github', _external=True) }}
</code>
</div>
</div>
</div>
<!-- Step 2: Repository Selection -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: Repository Selection') }}</h2>
<div class="space-y-4">
<div>
<label for="repositories" class="block text-sm font-medium mb-2">
{{ _('Repositories') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
</label>
<input type="text" id="repositories" name="repositories"
value="{{ current_config.get('repositories', '') }}"
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="owner/repo1, owner/repo2">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Comma-separated list of repositories (e.g., "octocat/Hello-World"). Leave empty to sync all accessible repositories.') }}
</p>
</div>
<div class="flex items-center">
<input type="checkbox" name="create_projects" id="create_projects" value="1"
{% if current_config.get('create_projects', True) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="create_projects" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Create Projects') }}
</label>
</div>
</div>
</div>
<!-- Step 3: Sync Configuration -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Sync Configuration') }}</h2>
<div class="space-y-4">
<div>
<label for="sync_direction" class="block text-sm font-medium mb-2">{{ _('Sync Direction') }}</label>
<select id="sync_direction" name="sync_direction"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="github_to_timetracker" {% if current_config.get('sync_direction', 'github_to_timetracker') == 'github_to_timetracker' %}selected{% endif %}>
{{ _('GitHub → TimeTracker (Import only)') }}
</option>
<option value="timetracker_to_github" {% if current_config.get('sync_direction') == 'timetracker_to_github' %}selected{% endif %}>
{{ _('TimeTracker → GitHub (Export only)') }}
</option>
<option value="bidirectional" {% if current_config.get('sync_direction') == 'bidirectional' %}selected{% endif %}>
{{ _('Bidirectional (Two-way sync)') }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Items to Sync') }}</label>
<div class="space-y-2">
{% set sync_items = current_config.get('sync_items', ['issues']) %}
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="issues" id="sync_issues"
{% if 'issues' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_issues" class="ml-2 text-sm">{{ _('Issues') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="pull_requests" id="sync_prs"
{% if 'pull_requests' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_prs" class="ml-2 text-sm">{{ _('Pull Requests') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="projects" id="sync_projects"
{% if 'projects' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_projects" class="ml-2 text-sm">{{ _('Projects (Repositories)') }}</label>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Issue States to Sync') }}</label>
<div class="space-y-2">
{% set issue_states = current_config.get('issue_states', ['open']) %}
<div class="flex items-center">
<input type="checkbox" name="issue_states" value="open" id="state_open"
{% if 'open' in issue_states %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="state_open" class="ml-2 text-sm">{{ _('Open Issues') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="issue_states" value="closed" id="state_closed"
{% if 'closed' in issue_states %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="state_closed" class="ml-2 text-sm">{{ _('Closed Issues') }}</label>
</div>
</div>
</div>
</div>
</div>
<!-- Step 4: Webhook Setup -->
<div class="wizard-step hidden" data-step="4">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 4: Webhook Setup') }}</h2>
<div class="space-y-4">
<div>
<label for="webhook_secret" class="block text-sm font-medium mb-2">
{{ _('Webhook Secret') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
</label>
<input type="password" id="webhook_secret" name="webhook_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="{{ _('Enter webhook secret') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Secret token for verifying webhook signatures. Configure this in your GitHub repository webhook settings.') }}
</p>
</div>
<div>
<label for="sync_interval" class="block text-sm font-medium mb-2">{{ _('Sync Schedule') }}</label>
<select id="sync_interval" name="sync_interval"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="manual" {% if current_config.get('sync_interval', 'manual') == 'manual' %}selected{% endif %}>{{ _('Manual only') }}</option>
<option value="hourly" {% if current_config.get('sync_interval') == 'hourly' %}selected{% endif %}>{{ _('Every hour') }}</option>
<option value="daily" {% if current_config.get('sync_interval') == 'daily' %}selected{% endif %}>{{ _('Daily') }}</option>
<option value="weekly" {% if current_config.get('sync_interval') == 'weekly' %}selected{% endif %}>{{ _('Weekly') }}</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" name="auto_sync" id="auto_sync" value="1"
{% if current_config.get('auto_sync', False) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="auto_sync" class="ml-2 text-sm">{{ _('Auto Sync') }}</label>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark ml-6">
{{ _('Automatically sync when webhooks are received from GitHub') }}
</p>
<div class="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">{{ _('Webhook URL') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">
{{ _('Add this URL as a webhook in your GitHub repository settings:') }}
</p>
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
{{ url_for('integrations.integration_webhook', provider='github', _external=True) }}
</code>
</div>
</div>
</div>
<!-- Step 5: Review -->
<div class="wizard-step hidden" data-step="5">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 5: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Repositories') }}:</span>
<span id="review-repositories" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Sync Direction') }}:</span>
<span id="review-sync-direction" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Items to Sync') }}:</span>
<span id="review-sync-items" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 5,
provider: 'github',
saveUrl: '{{ wizard_save_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 5) {
const repos = document.getElementById('repositories')?.value || '{{ _("All repositories") }}';
const syncDirection = document.getElementById('sync_direction')?.selectedOptions[0]?.text || '';
const syncItems = Array.from(document.querySelectorAll('input[name="sync_items"]:checked'))
.map(cb => cb.nextElementSibling.textContent).join(', ') || '{{ _("None") }}';
document.getElementById('review-repositories').textContent = repos;
document.getElementById('review-sync-direction').textContent = syncDirection;
document.getElementById('review-sync-items').textContent = syncItems;
}
}
});
wizard.init();
}
});
</script>
{% endblock %}
@@ -0,0 +1,264 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: Instance Configuration -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: Instance Configuration') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div>
<label for="gitlab_instance_url" class="block text-sm font-medium mb-2">
{{ _('GitLab Instance URL') }}
</label>
<input type="url" id="gitlab_instance_url" name="gitlab_instance_url"
value="{{ current_config.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">
<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 class="p-4 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>
{{ _('You can use GitLab.com or your self-hosted GitLab instance.') }}
</p>
</div>
</div>
</div>
<!-- Step 2: OAuth Setup -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: OAuth Setup') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Configure OAuth credentials. Create an application in GitLab Settings → Applications.') }}
</p>
</div>
<div>
<label for="gitlab_client_id" class="block text-sm font-medium mb-2">
{{ _('GitLab OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="gitlab_client_id" name="gitlab_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="{{ _('Enter OAuth Client ID') }}" required>
</div>
<div>
<label for="gitlab_client_secret" class="block text-sm font-medium mb-2">
{{ _('GitLab OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="gitlab_client_secret" name="gitlab_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-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 GitLab application 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='gitlab', _external=True) }}
</code>
</div>
</div>
</div>
<!-- Step 3: Repository Selection -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Repository Selection') }}</h2>
<div class="space-y-4">
<div>
<label for="repository_ids" class="block text-sm font-medium mb-2">
{{ _('Repository IDs') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
</label>
<input type="text" id="repository_ids" name="repository_ids"
value="{{ current_config.get('repository_ids', '') }}"
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="123456, 789012">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Comma-separated list of GitLab project IDs to sync. Leave empty to sync all accessible projects.') }}
</p>
</div>
<div class="flex items-center">
<input type="checkbox" name="create_projects" id="create_projects" value="1"
{% if current_config.get('create_projects', True) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="create_projects" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Create Projects') }}
</label>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark ml-6">
{{ _('Automatically create projects in TimeTracker from GitLab projects') }}
</p>
</div>
</div>
<!-- Step 4: Sync Settings -->
<div class="wizard-step hidden" data-step="4">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 4: Sync Settings') }}</h2>
<div class="space-y-4">
<div>
<label for="sync_direction" class="block text-sm font-medium mb-2">
{{ _('Sync Direction') }}
</label>
<select id="sync_direction" name="sync_direction"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="gitlab_to_timetracker" {% if current_config.get('sync_direction', 'gitlab_to_timetracker') == 'gitlab_to_timetracker' %}selected{% endif %}>
{{ _('GitLab → TimeTracker (Import only)') }}
</option>
<option value="timetracker_to_gitlab" {% if current_config.get('sync_direction') == 'timetracker_to_gitlab' %}selected{% endif %}>
{{ _('TimeTracker → GitLab (Export only)') }}
</option>
<option value="bidirectional" {% if current_config.get('sync_direction') == 'bidirectional' %}selected{% endif %}>
{{ _('Bidirectional (Two-way sync)') }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Items to Sync') }}</label>
<div class="space-y-2">
{% set sync_items = current_config.get('sync_items', ['issues']) %}
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="issues" id="sync_issues"
{% if 'issues' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_issues" class="ml-2 text-sm text-text-light dark:text-text-dark">{{ _('Issues') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="merge_requests" id="sync_merge_requests"
{% if 'merge_requests' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_merge_requests" class="ml-2 text-sm text-text-light dark:text-text-dark">{{ _('Merge Requests') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="projects" id="sync_projects"
{% if 'projects' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_projects" class="ml-2 text-sm text-text-light dark:text-text-dark">{{ _('Projects') }}</label>
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Issue States to Sync') }}</label>
<div class="space-y-2">
{% set issue_states = current_config.get('issue_states', ['opened']) %}
<div class="flex items-center">
<input type="checkbox" name="issue_states" value="opened" id="state_opened"
{% if 'opened' in issue_states %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="state_opened" class="ml-2 text-sm text-text-light dark:text-text-dark">{{ _('Open Issues') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="issue_states" value="closed" id="state_closed"
{% if 'closed' in issue_states %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="state_closed" class="ml-2 text-sm text-text-light dark:text-text-dark">{{ _('Closed Issues') }}</label>
</div>
</div>
</div>
<div>
<label for="sync_interval" class="block text-sm font-medium mb-2">
{{ _('Sync Schedule') }}
</label>
<select id="sync_interval" name="sync_interval"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="manual" {% if current_config.get('sync_interval', 'manual') == 'manual' %}selected{% endif %}>{{ _('Manual only') }}</option>
<option value="hourly" {% if current_config.get('sync_interval') == 'hourly' %}selected{% endif %}>{{ _('Every hour') }}</option>
<option value="daily" {% if current_config.get('sync_interval') == 'daily' %}selected{% endif %}>{{ _('Daily') }}</option>
<option value="weekly" {% if current_config.get('sync_interval') == 'weekly' %}selected{% endif %}>{{ _('Weekly') }}</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" name="auto_sync" id="auto_sync" value="1"
{% if current_config.get('auto_sync', False) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="auto_sync" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Auto Sync') }}
</label>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark ml-6">
{{ _('Automatically sync when webhooks are received from GitLab') }}
</p>
<div>
<label for="webhook_secret" class="block text-sm font-medium mb-2">
{{ _('Webhook Secret') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
</label>
<input type="password" id="webhook_secret" name="webhook_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="{{ _('Enter webhook secret') }}">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Secret token for verifying webhook signatures') }}
</p>
</div>
</div>
</div>
<!-- Step 5: Review -->
<div class="wizard-step hidden" data-step="5">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 5: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Instance URL') }}:</span>
<span id="review-instance-url" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Sync Direction') }}:</span>
<span id="review-sync-direction" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Items to Sync') }}:</span>
<span id="review-sync-items" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 5,
provider: 'gitlab',
saveUrl: '{{ wizard_save_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 5) {
const instanceUrl = document.getElementById('gitlab_instance_url')?.value || 'https://gitlab.com';
const syncDirection = document.getElementById('sync_direction')?.selectedOptions[0]?.text || '';
const syncItems = Array.from(document.querySelectorAll('input[name="sync_items"]:checked'))
.map(cb => cb.nextElementSibling.textContent).join(', ') || '{{ _("None") }}';
document.getElementById('review-instance-url').textContent = instanceUrl;
document.getElementById('review-sync-direction').textContent = syncDirection;
document.getElementById('review-sync-items').textContent = syncItems;
}
}
});
wizard.init();
}
});
</script>
{% endblock %}
+290
View File
@@ -0,0 +1,290 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: OAuth Setup and Basic Configuration -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: OAuth Setup & Basic Configuration') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-green-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('First, configure OAuth credentials for Jira. You can get these from Atlassian Developer Console.') }}
</p>
</div>
<div>
<label for="jira_client_id" class="block text-sm font-medium mb-2">
{{ _('Jira OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="jira_client_id" name="jira_client_id"
value="{{ current_config.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="{{ _('Enter OAuth Client ID') }}" required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Get this from Atlassian Developer Console') }}
</p>
</div>
<div>
<label for="jira_client_secret" class="block text-sm font-medium mb-2">
{{ _('Jira OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="jira_client_secret" name="jira_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
<div>
<label for="jira_url" class="block text-sm font-medium mb-2">
{{ _('Jira Instance URL') }} <span class="text-red-500">*</span>
</label>
<input type="url" id="jira_url" name="jira_url"
value="{{ current_config.get('jira_url', '') }}"
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://your-domain.atlassian.net" required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Your Jira Cloud or Server URL (e.g., https://your-domain.atlassian.net)') }}
</p>
</div>
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-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 Jira 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='jira', _external=True) }}
</code>
</div>
</div>
</div>
<!-- Step 2: Connection Test -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: Test Connection') }}</h2>
<div id="connection-test-results" class="mb-4">
<div class="p-4 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>
{{ _('Click "Test Connection" to verify that Jira is accessible and OAuth credentials are correct.') }}
</p>
</div>
</div>
<button type="button" id="test-connection-btn" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-vial mr-2"></i>{{ _('Test Connection') }}
</button>
</div>
<!-- Step 3: Sync Configuration -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Sync Configuration') }}</h2>
<div class="space-y-4">
<div>
<label for="jql" class="block text-sm font-medium mb-2">
{{ _('JQL Query') }} <span class="text-text-muted-light dark:text-text-muted-dark">({{ _('optional') }})</span>
</label>
<textarea id="jql" name="jql" rows="3"
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="assignee = currentUser() AND status != Done ORDER BY updated DESC">{{ current_config.get('jql', '') }}</textarea>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Jira Query Language query to filter issues to sync. Leave empty to sync all assigned issues.') }}
</p>
</div>
<div>
<label for="sync_direction" class="block text-sm font-medium mb-2">
{{ _('Sync Direction') }}
</label>
<select id="sync_direction" name="sync_direction"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="jira_to_timetracker" {% if current_config.get('sync_direction', 'jira_to_timetracker') == 'jira_to_timetracker' %}selected{% endif %}>
{{ _('Jira → TimeTracker (Import only)') }}
</option>
<option value="timetracker_to_jira" {% if current_config.get('sync_direction') == 'timetracker_to_jira' %}selected{% endif %}>
{{ _('TimeTracker → Jira (Export only)') }}
</option>
<option value="bidirectional" {% if current_config.get('sync_direction') == 'bidirectional' %}selected{% endif %}>
{{ _('Bidirectional (Two-way sync)') }}
</option>
</select>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Choose how data flows between Jira and TimeTracker') }}
</p>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Items to Sync') }}</label>
<div class="space-y-2">
{% set sync_items = current_config.get('sync_items', ['issues']) %}
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="issues" id="sync_issues"
{% if 'issues' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_issues" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Issues (Tasks)') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="projects" id="sync_projects"
{% if 'projects' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_projects" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Projects') }}
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="time_entries" id="sync_time_entries"
{% if 'time_entries' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_time_entries" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Time Entries') }}
</label>
</div>
</div>
</div>
</div>
</div>
<!-- Step 4: Advanced Settings -->
<div class="wizard-step hidden" data-step="4">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 4: Advanced Settings') }}</h2>
<div class="space-y-4">
<div class="flex items-center">
<input type="checkbox" name="auto_sync" id="auto_sync" value="1"
{% if current_config.get('auto_sync', False) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="auto_sync" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Auto Sync') }}
</label>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark ml-6">
{{ _('Automatically sync when webhooks are received from Jira') }}
</p>
<div>
<label for="sync_interval" class="block text-sm font-medium mb-2">
{{ _('Sync Schedule') }}
</label>
<select id="sync_interval" name="sync_interval"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="manual" {% if current_config.get('sync_interval', 'manual') == 'manual' %}selected{% endif %}>
{{ _('Manual only') }}
</option>
<option value="hourly" {% if current_config.get('sync_interval') == 'hourly' %}selected{% endif %}>
{{ _('Every hour') }}
</option>
<option value="daily" {% if current_config.get('sync_interval') == 'daily' %}selected{% endif %}>
{{ _('Daily') }}
</option>
<option value="weekly" {% if current_config.get('sync_interval') == 'weekly' %}selected{% endif %}>
{{ _('Weekly') }}
</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" name="create_projects" id="create_projects" value="1"
{% if current_config.get('create_projects', True) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="create_projects" class="ml-2 text-sm text-text-light dark:text-text-dark">
{{ _('Create Projects') }}
</label>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark ml-6">
{{ _('Automatically create projects in TimeTracker from Jira projects') }}
</p>
</div>
</div>
<!-- Step 5: Review & Save -->
<div class="wizard-step hidden" data-step="5">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 5: Review & Save') }}</h2>
<div class="space-y-4">
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration below. Click "Finish" to save and complete the setup.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Jira URL') }}:</span>
<span id="review-jira-url" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Sync Direction') }}:</span>
<span id="review-sync-direction" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Items to Sync') }}:</span>
<span id="review-sync-items" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Auto Sync') }}:</span>
<span id="review-auto-sync" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 5,
provider: 'jira',
saveUrl: '{{ wizard_save_url }}',
testConnectionUrl: '{{ test_connection_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 5) {
// Update review section
const jiraUrl = document.getElementById('jira_url')?.value || '';
const syncDirection = document.getElementById('sync_direction')?.selectedOptions[0]?.text || '';
const syncItems = Array.from(document.querySelectorAll('input[name="sync_items"]:checked'))
.map(cb => cb.nextElementSibling.textContent).join(', ');
const autoSync = document.getElementById('auto_sync')?.checked ? '{{ _("Yes") }}' : '{{ _("No") }}';
document.getElementById('review-jira-url').textContent = jiraUrl || '{{ _("Not set") }}';
document.getElementById('review-sync-direction').textContent = syncDirection;
document.getElementById('review-sync-items').textContent = syncItems || '{{ _("None") }}';
document.getElementById('review-auto-sync').textContent = autoSync;
}
}
});
wizard.addValidationCallback(2, function() {
if (!wizard.connectionTestResult) {
alert('{{ _("Please test the connection before proceeding.") }}');
return false;
}
return true;
});
// Handle connection test button
const testBtn = document.getElementById('test-connection-btn');
if (testBtn) {
testBtn.addEventListener('click', async function() {
const jiraUrl = document.getElementById('jira_url')?.value;
if (!jiraUrl) {
alert('{{ _("Please enter Jira URL first.") }}');
return;
}
const result = await wizard.testConnection({ jira_url: jiraUrl });
wizard.displayConnectionResults(result);
});
}
wizard.init();
}
});
</script>
{% endblock %}
@@ -0,0 +1,111 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: Tenant ID -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: Tenant ID Configuration') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Enter your Azure AD tenant ID. Use "common" for multi-tenant apps or leave empty for default.') }}
</p>
</div>
<div>
<label for="microsoft_teams_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" id="microsoft_teams_tenant_id" name="microsoft_teams_tenant_id"
value="{{ current_config.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="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 %}
</div>
</div>
<!-- Step 2: OAuth Setup -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: OAuth Setup') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Configure OAuth credentials from Azure AD App Registrations.') }}
</p>
</div>
<div>
<label for="microsoft_teams_client_id" class="block text-sm font-medium mb-2">
{{ _('Microsoft Teams OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="microsoft_teams_client_id" name="microsoft_teams_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="{{ _('Enter OAuth Client ID') }}" required>
</div>
<div>
<label for="microsoft_teams_client_secret" class="block text-sm font-medium mb-2">
{{ _('Microsoft Teams OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="microsoft_teams_client_secret" name="microsoft_teams_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h3 class="font-semibold mb-2">{{ _('OAuth Redirect URI') }}</h3>
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
{{ url_for('integrations.oauth_callback', provider='microsoft_teams', _external=True) }}
</code>
</div>
</div>
</div>
<!-- Step 3: Review -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Tenant ID') }}:</span>
<span id="review-tenant-id" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 3,
provider: 'microsoft_teams',
saveUrl: '{{ wizard_save_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 3) {
const tenantId = document.getElementById('microsoft_teams_tenant_id')?.value || 'common (default)';
document.getElementById('review-tenant-id').textContent = tenantId;
}
}
});
wizard.init();
}
});
</script>
{% endblock %}
@@ -0,0 +1,111 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: Tenant ID -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: Tenant ID Configuration') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Enter your Azure AD tenant ID. Use "common" for multi-tenant apps or leave empty for default.') }}
</p>
</div>
<div>
<label for="outlook_calendar_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" id="outlook_calendar_tenant_id" name="outlook_calendar_tenant_id"
value="{{ current_config.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="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 %}
</div>
</div>
<!-- Step 2: OAuth Setup -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: OAuth Setup') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Configure OAuth credentials from Azure AD App Registrations.') }}
</p>
</div>
<div>
<label for="outlook_calendar_client_id" class="block text-sm font-medium mb-2">
{{ _('Outlook Calendar OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="outlook_calendar_client_id" name="outlook_calendar_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="{{ _('Enter OAuth Client ID') }}" required>
</div>
<div>
<label for="outlook_calendar_client_secret" class="block text-sm font-medium mb-2">
{{ _('Outlook Calendar OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="outlook_calendar_client_secret" name="outlook_calendar_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
<div class="p-4 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg">
<h3 class="font-semibold mb-2">{{ _('OAuth Redirect URI') }}</h3>
<code class="block p-2 bg-gray-100 dark:bg-gray-800 rounded text-xs break-all">
{{ url_for('integrations.oauth_callback', provider='outlook_calendar', _external=True) }}
</code>
</div>
</div>
</div>
<!-- Step 3: Review -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save. After saving, users can connect their Outlook Calendar accounts.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Tenant ID') }}:</span>
<span id="review-tenant-id" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 3,
provider: 'outlook_calendar',
saveUrl: '{{ wizard_save_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 3) {
const tenantId = document.getElementById('outlook_calendar_tenant_id')?.value || 'common (default)';
document.getElementById('review-tenant-id').textContent = tenantId;
}
}
});
wizard.init();
}
});
</script>
{% endblock %}
@@ -0,0 +1,239 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: OAuth Connection -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: OAuth Connection') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Configure OAuth credentials from Intuit Developer Dashboard.') }}
</p>
</div>
<div>
<label for="quickbooks_client_id" class="block text-sm font-medium mb-2">
{{ _('QuickBooks OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="quickbooks_client_id" name="quickbooks_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="{{ _('Enter OAuth Client ID') }}" required>
</div>
<div>
<label for="quickbooks_client_secret" class="block text-sm font-medium mb-2">
{{ _('QuickBooks OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="quickbooks_client_secret" name="quickbooks_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
<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-exclamation-triangle mr-2"></i>
{{ _('After saving OAuth credentials, you will need to connect your QuickBooks account via OAuth.') }}
</p>
</div>
</div>
</div>
<!-- Step 2: Company Selection -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: Company Selection') }}</h2>
<div class="space-y-4">
<div>
<label for="realm_id" class="block text-sm font-medium mb-2">
{{ _('Company ID (Realm ID)') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="realm_id" name="realm_id"
value="{{ current_config.get('realm_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="123456789" required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Find your company ID (realm ID) in QuickBooks after connecting via OAuth.') }}
</p>
</div>
<div class="flex items-center">
<input type="checkbox" name="use_sandbox" id="use_sandbox" value="1"
{% if current_config.get('use_sandbox', True) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="use_sandbox" class="ml-2 text-sm">{{ _('Use Sandbox') }}</label>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark ml-6">
{{ _('Use QuickBooks sandbox environment for testing') }}
</p>
</div>
</div>
<!-- Step 3: Sync Configuration -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Sync Configuration') }}</h2>
<div class="space-y-4">
<div>
<label for="sync_direction" class="block text-sm font-medium mb-2">{{ _('Sync Direction') }}</label>
<select id="sync_direction" name="sync_direction"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="quickbooks_to_timetracker" {% if current_config.get('sync_direction', 'timetracker_to_quickbooks') == 'quickbooks_to_timetracker' %}selected{% endif %}>
{{ _('QuickBooks → TimeTracker (Import only)') }}
</option>
<option value="timetracker_to_quickbooks" {% if current_config.get('sync_direction', 'timetracker_to_quickbooks') == 'timetracker_to_quickbooks' %}selected{% endif %}>
{{ _('TimeTracker → QuickBooks (Export only)') }}
</option>
<option value="bidirectional" {% if current_config.get('sync_direction') == 'bidirectional' %}selected{% endif %}>
{{ _('Bidirectional (Two-way sync)') }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Items to Sync') }}</label>
<div class="space-y-2">
{% set sync_items = current_config.get('sync_items', ['invoices', 'expenses']) %}
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="invoices" id="sync_invoices"
{% if 'invoices' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_invoices" class="ml-2 text-sm">{{ _('Invoices') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="expenses" id="sync_expenses"
{% if 'expenses' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_expenses" class="ml-2 text-sm">{{ _('Expenses') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="payments" id="sync_payments"
{% if 'payments' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_payments" class="ml-2 text-sm">{{ _('Payments') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="customers" id="sync_customers"
{% if 'customers' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_customers" class="ml-2 text-sm">{{ _('Customers') }}</label>
</div>
</div>
</div>
<div>
<label for="sync_interval" class="block text-sm font-medium mb-2">{{ _('Sync Schedule') }}</label>
<select id="sync_interval" name="sync_interval"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="manual" {% if current_config.get('sync_interval', 'manual') == 'manual' %}selected{% endif %}>{{ _('Manual only') }}</option>
<option value="hourly" {% if current_config.get('sync_interval') == 'hourly' %}selected{% endif %}>{{ _('Every hour') }}</option>
<option value="daily" {% if current_config.get('sync_interval') == 'daily' %}selected{% endif %}>{{ _('Daily') }}</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" name="auto_sync" id="auto_sync" value="1"
{% if current_config.get('auto_sync', False) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="auto_sync" class="ml-2 text-sm">{{ _('Auto Sync') }}</label>
</div>
</div>
</div>
<!-- Step 4: Account Mappings -->
<div class="wizard-step hidden" data-step="4">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 4: Account Mappings') }} <span class="text-text-muted-light dark:text-text-muted-dark text-sm font-normal">({{ _('optional') }})</span></h2>
<div class="space-y-4">
<div>
<label for="default_expense_account_id" class="block text-sm font-medium mb-2">
{{ _('Default Expense Account ID') }}
</label>
<input type="text" id="default_expense_account_id" name="default_expense_account_id"
value="{{ current_config.get('default_expense_account_id', '1') }}"
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="1">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('QuickBooks account ID to use for expenses when no mapping is configured') }}
</p>
</div>
<div>
<label for="customer_mappings" class="block text-sm font-medium mb-2">
{{ _('Customer Mappings') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs font-normal">({{ _('JSON') }})</span>
</label>
<textarea id="customer_mappings" name="customer_mappings" 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='{"1": "qb_customer_id_123", "2": "qb_customer_id_456"}'>{{ current_config.get('customer_mappings', '')|tojson|safe if current_config.get('customer_mappings') else '' }}</textarea>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Map TimeTracker client IDs to QuickBooks customer IDs (JSON format)') }}
</p>
</div>
<div>
<label for="account_mappings" class="block text-sm font-medium mb-2">
{{ _('Account Mappings') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs font-normal">({{ _('JSON') }})</span>
</label>
<textarea id="account_mappings" name="account_mappings" 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='{"expense_category_1": "qb_account_id_123"}'>{{ current_config.get('account_mappings', '')|tojson|safe if current_config.get('account_mappings') else '' }}</textarea>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Map TimeTracker expense category IDs to QuickBooks account IDs (JSON format)') }}
</p>
</div>
</div>
</div>
<!-- Step 5: Review -->
<div class="wizard-step hidden" data-step="5">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 5: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Company ID') }}:</span>
<span id="review-realm-id" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Sync Direction') }}:</span>
<span id="review-sync-direction" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Items to Sync') }}:</span>
<span id="review-sync-items" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 5,
provider: 'quickbooks',
saveUrl: '{{ wizard_save_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 5) {
const realmId = document.getElementById('realm_id')?.value || '{{ _("Not set") }}';
const syncDirection = document.getElementById('sync_direction')?.selectedOptions[0]?.text || '';
const syncItems = Array.from(document.querySelectorAll('input[name="sync_items"]:checked'))
.map(cb => cb.nextElementSibling.textContent).join(', ') || '{{ _("None") }}';
document.getElementById('review-realm-id').textContent = realmId;
document.getElementById('review-sync-direction').textContent = syncDirection;
document.getElementById('review-sync-items').textContent = syncItems;
}
}
});
wizard.init();
}
});
</script>
{% endblock %}
@@ -0,0 +1,118 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: API Keys -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: API Keys Setup') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Get your API key and secret from') }} <a href="https://trello.com/app-key" target="_blank" class="underline">trello.com/app-key</a>
</p>
</div>
<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" id="trello_api_key" name="trello_api_key"
value="{{ current_config.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="{{ _('Enter API Key') }}" required>
</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" id="trello_api_secret" name="trello_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="{{ _('Enter API Secret') }}" required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Generate a token after creating the API key') }}
</p>
</div>
{% endif %}
</div>
</div>
<!-- Step 2: Connection Test -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: Test Connection') }}</h2>
<div id="connection-test-results" class="mb-4">
<div class="p-4 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>
{{ _('Click "Test Connection" to verify that your Trello API credentials are correct.') }}
</p>
</div>
</div>
<button type="button" id="test-connection-btn" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-vial mr-2"></i>{{ _('Test Connection') }}
</button>
</div>
<!-- Step 3: Review -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('API Key') }}:</span>
<span class="text-text-muted-light dark:text-text-muted-dark">••••••••</span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Status') }}:</span>
<span id="review-connection-status" class="text-text-muted-light dark:text-text-muted-dark">{{ _('Not tested') }}</span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 3,
provider: 'trello',
saveUrl: '{{ wizard_save_url }}',
testConnectionUrl: '{{ test_connection_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 3 && wizard.connectionTestResult) {
const status = wizard.connectionTestResult.success ? '{{ _("Connected") }}' : '{{ _("Connection failed") }}';
document.getElementById('review-connection-status').textContent = status;
}
}
});
wizard.addValidationCallback(2, function() {
if (!wizard.connectionTestResult) {
alert('{{ _("Please test the connection before proceeding.") }}');
return false;
}
return true;
});
const testBtn = document.getElementById('test-connection-btn');
if (testBtn) {
testBtn.addEventListener('click', async function() {
const result = await wizard.testConnection({});
wizard.displayConnectionResults(result);
});
}
wizard.init();
}
});
</script>
{% endblock %}
+222
View File
@@ -0,0 +1,222 @@
{% extends "integrations/wizard_base.html" %}
{% block wizard_steps %}
<!-- Step 1: OAuth Connection -->
<div class="wizard-step" data-step="1">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 1: OAuth Connection') }}</h2>
<div class="space-y-4">
{% if current_user.is_admin %}
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg mb-4">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Configure OAuth credentials from Xero Developer Portal.') }}
</p>
</div>
<div>
<label for="xero_client_id" class="block text-sm font-medium mb-2">
{{ _('Xero OAuth Client ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="xero_client_id" name="xero_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="{{ _('Enter OAuth Client ID') }}" required>
</div>
<div>
<label for="xero_client_secret" class="block text-sm font-medium mb-2">
{{ _('Xero OAuth Client Secret') }} <span class="text-red-500">*</span>
</label>
<input type="password" id="xero_client_secret" name="xero_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="{{ _('Enter OAuth Client Secret') }}" required>
</div>
{% endif %}
</div>
</div>
<!-- Step 2: Tenant Selection -->
<div class="wizard-step hidden" data-step="2">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 2: Tenant Selection') }}</h2>
<div class="space-y-4">
<div>
<label for="tenant_id" class="block text-sm font-medium mb-2">
{{ _('Tenant ID') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="tenant_id" name="tenant_id"
value="{{ current_config.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="tenant-uuid-123" required>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Find your tenant ID (organisation ID) in Xero after connecting via OAuth.') }}
</p>
</div>
</div>
</div>
<!-- Step 3: Sync Configuration -->
<div class="wizard-step hidden" data-step="3">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 3: Sync Configuration') }}</h2>
<div class="space-y-4">
<div>
<label for="sync_direction" class="block text-sm font-medium mb-2">{{ _('Sync Direction') }}</label>
<select id="sync_direction" name="sync_direction"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="xero_to_timetracker" {% if current_config.get('sync_direction', 'timetracker_to_xero') == 'xero_to_timetracker' %}selected{% endif %}>
{{ _('Xero → TimeTracker (Import only)') }}
</option>
<option value="timetracker_to_xero" {% if current_config.get('sync_direction', 'timetracker_to_xero') == 'timetracker_to_xero' %}selected{% endif %}>
{{ _('TimeTracker → Xero (Export only)') }}
</option>
<option value="bidirectional" {% if current_config.get('sync_direction') == 'bidirectional' %}selected{% endif %}>
{{ _('Bidirectional (Two-way sync)') }}
</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-2">{{ _('Items to Sync') }}</label>
<div class="space-y-2">
{% set sync_items = current_config.get('sync_items', ['invoices', 'expenses']) %}
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="invoices" id="sync_invoices"
{% if 'invoices' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_invoices" class="ml-2 text-sm">{{ _('Invoices') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="expenses" id="sync_expenses"
{% if 'expenses' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_expenses" class="ml-2 text-sm">{{ _('Expenses') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="payments" id="sync_payments"
{% if 'payments' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_payments" class="ml-2 text-sm">{{ _('Payments') }}</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="sync_items" value="contacts" id="sync_contacts"
{% if 'contacts' in sync_items %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="sync_contacts" class="ml-2 text-sm">{{ _('Contacts') }}</label>
</div>
</div>
</div>
<div>
<label for="sync_interval" class="block text-sm font-medium mb-2">{{ _('Sync Schedule') }}</label>
<select id="sync_interval" name="sync_interval"
class="w-full px-3 py-2 border border-border-light dark:border-border-dark rounded-lg bg-card-light dark:bg-card-dark">
<option value="manual" {% if current_config.get('sync_interval', 'manual') == 'manual' %}selected{% endif %}>{{ _('Manual only') }}</option>
<option value="hourly" {% if current_config.get('sync_interval') == 'hourly' %}selected{% endif %}>{{ _('Every hour') }}</option>
<option value="daily" {% if current_config.get('sync_interval') == 'daily' %}selected{% endif %}>{{ _('Daily') }}</option>
</select>
</div>
<div class="flex items-center">
<input type="checkbox" name="auto_sync" id="auto_sync" value="1"
{% if current_config.get('auto_sync', False) %}checked{% endif %}
class="h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary">
<label for="auto_sync" class="ml-2 text-sm">{{ _('Auto Sync') }}</label>
</div>
</div>
</div>
<!-- Step 4: Mappings -->
<div class="wizard-step hidden" data-step="4">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 4: Account Mappings') }} <span class="text-text-muted-light dark:text-text-muted-dark text-sm font-normal">({{ _('optional') }})</span></h2>
<div class="space-y-4">
<div>
<label for="default_expense_account_code" class="block text-sm font-medium mb-2">
{{ _('Default Expense Account Code') }}
</label>
<input type="text" id="default_expense_account_code" name="default_expense_account_code"
value="{{ current_config.get('default_expense_account_code', '200') }}"
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="200">
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Xero account code to use for expenses when no mapping is configured') }}
</p>
</div>
<div>
<label for="contact_mappings" class="block text-sm font-medium mb-2">
{{ _('Contact Mappings') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs font-normal">({{ _('JSON') }})</span>
</label>
<textarea id="contact_mappings" name="contact_mappings" 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='{"1": "contact-uuid-123", "2": "contact-uuid-456"}'>{{ current_config.get('contact_mappings', '')|tojson|safe if current_config.get('contact_mappings') else '' }}</textarea>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Map TimeTracker client IDs to Xero Contact IDs (JSON format)') }}
</p>
</div>
<div>
<label for="account_mappings" class="block text-sm font-medium mb-2">
{{ _('Account Mappings') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs font-normal">({{ _('JSON') }})</span>
</label>
<textarea id="account_mappings" name="account_mappings" 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='{"expense_category_1": "account_code_123"}'>{{ current_config.get('account_mappings', '')|tojson|safe if current_config.get('account_mappings') else '' }}</textarea>
<p class="mt-1 text-xs text-text-muted-light dark:text-text-muted-dark">
{{ _('Map TimeTracker expense category IDs to Xero account codes (JSON format)') }}
</p>
</div>
</div>
</div>
<!-- Step 5: Review -->
<div class="wizard-step hidden" data-step="5">
<h2 class="text-xl font-semibold mb-4">{{ _('Step 5: Review & Save') }}</h2>
<div class="p-4 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 mb-4">
<i class="fas fa-info-circle mr-2"></i>
{{ _('Review your configuration and click "Finish" to save.') }}
</p>
<div class="space-y-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">{{ _('Tenant ID') }}:</span>
<span id="review-tenant-id" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Sync Direction') }}:</span>
<span id="review-sync-direction" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
<div class="flex justify-between">
<span class="font-medium">{{ _('Items to Sync') }}:</span>
<span id="review-sync-items" class="text-text-muted-light dark:text-text-muted-dark"></span>
</div>
</div>
</div>
<input type="hidden" name="wizard_final_step" value="true">
</div>
{% endblock %}
{% block wizard_js %}
<script>
document.addEventListener('DOMContentLoaded', function() {
if (typeof IntegrationWizard !== 'undefined') {
const wizard = new IntegrationWizard({
totalSteps: 5,
provider: 'xero',
saveUrl: '{{ wizard_save_url }}',
finishText: '{{ _("Finish") }}',
onStepChange: function(step) {
if (step === 5) {
const tenantId = document.getElementById('tenant_id')?.value || '{{ _("Not set") }}';
const syncDirection = document.getElementById('sync_direction')?.selectedOptions[0]?.text || '';
const syncItems = Array.from(document.querySelectorAll('input[name="sync_items"]:checked'))
.map(cb => cb.nextElementSibling.textContent).join(', ') || '{{ _("None") }}';
document.getElementById('review-tenant-id').textContent = tenantId;
document.getElementById('review-sync-direction').textContent = syncDirection;
document.getElementById('review-sync-items').textContent = syncItems;
}
}
});
wizard.init();
}
});
</script>
{% endblock %}