Files
gh-christianlouis-docuelevate/frontend/templates/integrations_dashboard.html
google-labs-jules[bot] d22175310a 🛡️ Sentinel: [HIGH] Fix Server-Side Request Forgery in IMAP connections
🚨 Severity: HIGH
💡 Vulnerability: User-provided IMAP `host` in `_test_imap_connection` and `pull_inbox` was not validated against private IPs, creating an SSRF risk.
🎯 Impact: Attackers could abuse the endpoints to port-scan or interact with internal/private network services.
🔧 Fix: Integrated `is_private_ip` from `app.utils.network` to block connections resolving to private, loopback, link-local, or reserved IPs.
 Verification: Ran `test_imap_tasks.py` and `test_api_imap_accounts.py` successfully. Checked `ruff` output and diffs. Removed all scratch files from the commit.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-23 14:45:22 +00:00

1467 lines
88 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{% extends "base.html" %}
{% block title %}{{ _("integrations.page_title") }} DocuElevate{% endblock %}
{% block content %}
<div
class="container mx-auto px-4 py-8"
x-data="integrationsDashboard()"
x-init="init()"
>
<!-- ── Header ───────────────────────────────────────────────────────────── -->
<div class="mb-6 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-plug text-indigo-500" aria-hidden="true"></i>
{{ _("integrations.page_title") }}
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
{{ _("integrations.subtitle") }}
</p>
</div>
<button
type="button"
@click="openCreateModal()"
:disabled="!canAddAny"
class="inline-flex items-center px-4 py-2 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
style="min-height:44px;"
:aria-disabled="!canAddAny"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("integrations.add_button") }}
</button>
</div>
<!-- ── Quota indicators ─────────────────────────────────────────────────── -->
<div class="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-4">
<!-- Sources quota -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow px-5 py-4">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-blue-100 dark:bg-blue-900 flex items-center justify-center flex-shrink-0">
<i class="fas fa-inbox text-blue-600 dark:text-blue-400" aria-hidden="true"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ _("integrations.sources_quota_label") }}
<strong x-text="quota.src_count"></strong>
<template x-if="quota.max_sources !== null && quota.max_sources > 0">
<span> / <strong x-text="quota.max_sources"></strong></span>
</template>
<template x-if="quota.max_sources === null">
<span class="text-gray-400"> {{ _("integrations.quota_unlimited") }}</span>
</template>
<template x-if="quota.max_sources === 0">
<span class="text-gray-400 ml-1">{{ _("integrations.quota_not_available") }}</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
</p>
</div>
<!-- Mini usage bar -->
<template x-if="quota.max_sources !== null && quota.max_sources > 0">
<div class="w-24 flex-shrink-0">
<div class="bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all"
:class="quota.src_count >= quota.max_sources ? 'bg-red-500' : 'bg-blue-500'"
:style="`width:${Math.min(100, quota.src_count / quota.max_sources * 100)}%`"
role="progressbar"
:aria-valuenow="quota.src_count"
:aria-valuemin="0"
:aria-valuemax="quota.max_sources"
:aria-label="`${quota.src_count} of ${quota.max_sources} source integrations used`"
></div>
</div>
</div>
</template>
</div>
<template x-if="quota.max_sources !== null && quota.src_count >= quota.max_sources && quota.max_sources >= 0">
<a href="/subscription" class="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800 mt-2">
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> {{ _("integrations.upgrade_plan") }}
</a>
</template>
</div>
<!-- Destinations quota -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow px-5 py-4">
<div class="flex items-center gap-3">
<div class="h-10 w-10 rounded-full bg-green-100 dark:bg-green-900 flex items-center justify-center flex-shrink-0">
<i class="fas fa-hdd text-green-600 dark:text-green-400" aria-hidden="true"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium text-gray-700 dark:text-gray-200">
{{ _("integrations.destinations_quota_label") }}
<strong x-text="quota.dest_count"></strong>
<template x-if="quota.max_destinations !== null">
<span> / <strong x-text="quota.max_destinations"></strong></span>
</template>
<template x-if="quota.max_destinations === null">
<span class="text-gray-400"> {{ _("integrations.quota_unlimited") }}</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
{{ _("integrations.plan_label") }} <span x-text="quota.tier_name"></span>
</p>
</div>
<!-- Mini usage bar -->
<template x-if="quota.max_destinations !== null && quota.max_destinations > 0">
<div class="w-24 flex-shrink-0">
<div class="bg-gray-200 dark:bg-gray-600 rounded-full h-2 overflow-hidden">
<div
class="h-2 rounded-full transition-all"
:class="quota.dest_count >= quota.max_destinations ? 'bg-red-500' : 'bg-green-500'"
:style="`width:${Math.min(100, quota.dest_count / quota.max_destinations * 100)}%`"
role="progressbar"
:aria-valuenow="quota.dest_count"
:aria-valuemin="0"
:aria-valuemax="quota.max_destinations"
:aria-label="`${quota.dest_count} of ${quota.max_destinations} destination integrations used`"
></div>
</div>
</div>
</template>
</div>
<template x-if="quota.max_destinations !== null && quota.dest_count >= quota.max_destinations">
<a href="/subscription" class="inline-flex items-center text-xs text-indigo-600 hover:text-indigo-800 mt-2">
<i class="fas fa-arrow-up mr-1" aria-hidden="true"></i> {{ _("integrations.upgrade_plan") }}
</a>
</template>
</div>
</div>
<!-- ── Alert banner ─────────────────────────────────────────────────────── -->
<div aria-live="polite" aria-atomic="true">
<div x-show="alert.show && alert.type !== 'error'" x-transition class="mb-4" role="status">
<div class="bg-green-50 border-green-400 text-green-800 border-l-4 p-4 rounded dark:bg-opacity-10">
<p class="font-semibold" x-text="alert.title"></p>
<p class="text-sm" x-text="alert.message"></p>
</div>
</div>
</div>
<div aria-live="assertive" aria-atomic="true">
<div x-show="alert.show && alert.type === 'error'" x-transition class="mb-4" role="alert">
<div class="bg-red-50 border-red-400 text-red-800 border-l-4 p-4 rounded dark:bg-opacity-10">
<p class="font-semibold" x-text="alert.title"></p>
<p class="text-sm" x-text="alert.message"></p>
</div>
</div>
</div>
<!-- ── Loading ──────────────────────────────────────────────────────────── -->
<template x-if="loading">
<div class="text-center py-12 text-gray-400">
<i class="fas fa-spinner fa-spin text-3xl mb-3" aria-hidden="true"></i>
<p>{{ _("integrations.loading") }}</p>
</div>
</template>
<!-- ── Empty state ──────────────────────────────────────────────────────── -->
<template x-if="!loading && integrations.length === 0">
<div class="text-center py-16 bg-white dark:bg-gray-800 rounded-lg shadow">
<i class="fas fa-plug text-5xl text-gray-300 mb-4" aria-hidden="true"></i>
<h2 class="text-xl font-semibold text-gray-700 dark:text-gray-300 mb-2">{{ _("integrations.empty_state") }}</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
{{ _("integrations.no_integrations_body") }}
</p>
<button
type="button"
@click="openCreateModal()"
:disabled="!canAddAny"
class="inline-flex items-center px-5 py-2.5 bg-indigo-600 hover:bg-indigo-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-md"
style="min-height:44px;"
>
<i class="fas fa-plus mr-2" aria-hidden="true"></i> {{ _("integrations.add_first_button") }}
</button>
</div>
</template>
<!-- ── Section: Sources ─────────────────────────────────────────────────── -->
<template x-if="!loading && sources.length > 0">
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center gap-2">
<i class="fas fa-inbox text-blue-500" aria-hidden="true"></i> {{ _("integrations.sources_section") }}
</h2>
<div class="space-y-3">
<template x-for="intg in sources" :key="intg.id">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden border border-gray-100 dark:border-gray-700">
<div class="p-5 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex items-start gap-3 flex-1 min-w-0">
<div class="mt-0.5 flex-shrink-0">
<i class="fas text-lg" :class="typeIcon(intg.integration_type)" aria-hidden="true"></i>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate" x-text="intg.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 text-blue-700 dark:bg-blue-900 dark:text-blue-300" x-text="intg.integration_type"></span>
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="intg.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
x-text="intg.is_active ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
></span>
</p>
<template x-if="intg.last_error">
<p class="mt-1.5 text-xs text-red-600 bg-red-50 rounded px-2 py-1 break-words" x-text="intg.last_error"></p>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<template x-if="isOAuthType(intg)">
<a
:href="oauthLink(intg) + '?integration_id=' + intg.id"
:class="intg.has_credentials
? 'inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-blue-300 text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
: 'inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-yellow-400 text-yellow-700 bg-yellow-50 hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-yellow-500'"
style="min-height:36px;"
:aria-label="`${intg.has_credentials ? 'Re-authorize' : 'Authorize'} ${intg.name}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i>
<span x-text="intg.has_credentials ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
</a>
</template>
<button
type="button"
@click="testSavedIntegration(intg)"
:disabled="testingId === intg.id"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
style="min-height:36px;"
:aria-label="`Test connection for ${intg.name}`"
>
<template x-if="testingId === intg.id">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<template x-if="testingId !== intg.id">
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
</template>
{{ _("common.test") }}
</button>
<button
type="button"
@click="openEditModal(intg)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:36px;"
:aria-label="`Edit ${intg.name}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i>{{ _("common.edit") }}
</button>
<button
type="button"
@click="confirmDelete(intg)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-red-200 text-red-600 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500"
style="min-height:36px;"
:aria-label="`Delete ${intg.name}`"
>
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("common.delete") }}
</button>
</div>
</div>
</div>
</template>
</div>
</section>
</template>
<!-- ── Section: Destinations ────────────────────────────────────────────── -->
<template x-if="!loading && destinations.length > 0">
<section class="mb-8">
<h2 class="text-lg font-semibold text-gray-800 dark:text-gray-200 mb-3 flex items-center gap-2">
<i class="fas fa-hdd text-green-500" aria-hidden="true"></i> {{ _("integrations.destinations_section") }}
</h2>
<div class="space-y-3">
<template x-for="intg in destinations" :key="intg.id">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden border border-gray-100 dark:border-gray-700">
<div class="p-5 flex flex-col sm:flex-row sm:items-center gap-4">
<div class="flex items-start gap-3 flex-1 min-w-0">
<div class="mt-0.5 flex-shrink-0">
<i class="fas text-lg" :class="typeIcon(intg.integration_type)" aria-hidden="true"></i>
</div>
<div class="min-w-0 flex-1">
<h3 class="text-base font-semibold text-gray-900 dark:text-white truncate" x-text="intg.name"></h3>
<p class="text-sm text-gray-500 dark:text-gray-400">
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300" x-text="intg.integration_type"></span>
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="intg.is_active ? 'bg-green-50 text-green-700 dark:bg-green-900 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400'"
x-text="intg.is_active ? '{{ _('common.active') }}' : '{{ _('integrations.paused') }}'"
></span>
</p>
<template x-if="intg.last_error">
<p class="mt-1.5 text-xs text-red-600 bg-red-50 rounded px-2 py-1 break-words" x-text="intg.last_error"></p>
</template>
</div>
</div>
<!-- Actions -->
<div class="flex items-center gap-2 shrink-0">
<template x-if="isOAuthType(intg)">
<a
:href="oauthLink(intg) + '?integration_id=' + intg.id"
:class="intg.has_credentials
? 'inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-blue-300 text-blue-700 bg-blue-50 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-500'
: 'inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-yellow-400 text-yellow-700 bg-yellow-50 hover:bg-yellow-100 focus:outline-none focus:ring-2 focus:ring-yellow-500'"
style="min-height:36px;"
:aria-label="`${intg.has_credentials ? 'Re-authorize' : 'Authorize'} ${intg.name}`"
>
<i class="fas fa-key mr-1" aria-hidden="true"></i>
<span x-text="intg.has_credentials ? '{{ _('integrations.authorize_reauth') }}' : '{{ _('integrations.authorize_btn') }}'"></span>
</a>
</template>
<button
type="button"
@click="testSavedIntegration(intg)"
:disabled="testingId === intg.id"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
style="min-height:36px;"
:aria-label="`Test connection for ${intg.name}`"
>
<template x-if="testingId === intg.id">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
</template>
<template x-if="testingId !== intg.id">
<i class="fas fa-plug mr-1" aria-hidden="true"></i>
</template>
{{ _("common.test") }}
</button>
<button
type="button"
@click="openEditModal(intg)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500"
style="min-height:36px;"
:aria-label="`Edit ${intg.name}`"
>
<i class="fas fa-edit mr-1" aria-hidden="true"></i>{{ _("common.edit") }}
</button>
<button
type="button"
@click="confirmDelete(intg)"
class="inline-flex items-center px-3 py-1.5 text-xs font-medium rounded border border-red-200 text-red-600 hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500"
style="min-height:36px;"
:aria-label="`Delete ${intg.name}`"
>
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>{{ _("common.delete") }}
</button>
</div>
</div>
</div>
</template>
</div>
</section>
</template>
<!-- ── Create / Edit modal ──────────────────────────────────────────────── -->
<div
x-show="modalOpen"
x-transition:enter="ease-out duration-200"
x-transition:enter-start="opacity-0"
x-transition:enter-end="opacity-100"
x-transition:leave="ease-in duration-150"
x-transition:leave-start="opacity-100"
x-transition:leave-end="opacity-0"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
role="dialog"
aria-modal="true"
:aria-labelledby="editingIntegration ? 'modal-title-edit' : 'modal-title-create'"
@keydown.escape.window="closeModal()"
style="display:none;"
>
<div
class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]"
@click.stop
>
<div class="px-6 pt-6 pb-2 flex items-center justify-between">
<h2
:id="editingIntegration ? 'modal-title-edit' : 'modal-title-create'"
class="text-lg font-semibold text-gray-900 dark:text-white"
x-text="editingIntegration ? '{{ _('integrations.modal_title_edit') }}' : '{{ _('integrations.modal_title_add') }}'"
></h2>
<button
type="button"
@click="closeModal()"
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded"
aria-label="{{ _('integrations.modal_close') }}"
>
<i class="fas fa-times text-xl" aria-hidden="true"></i>
</button>
</div>
<form @submit.prevent="saveIntegration()" class="px-6 pb-6 pt-4 space-y-4">
<!-- Direction -->
<div>
<label for="intg-direction" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
{{ _("integrations.direction_label") }} <span class="text-red-500" aria-hidden="true">*</span>
</label>
<select
id="intg-direction"
x-model="form.direction"
required
:disabled="!!editingIntegration"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
@change="onDirectionChange()"
>
<option value="">{{ _("integrations.direction_placeholder") }}</option>
<option value="SOURCE">{{ _("integrations.direction_source") }}</option>
<option value="DESTINATION">{{ _("integrations.direction_destination") }}</option>
</select>
</div>
<!-- Integration type -->
<div>
<label for="intg-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
{{ _("integrations.type_label") }} <span class="text-red-500" aria-hidden="true">*</span>
</label>
<select
id="intg-type"
x-model="form.integration_type"
required
:disabled="!!editingIntegration"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
@change="onTypeChange()"
>
<option value="">{{ _("integrations.type_placeholder") }}</option>
<template x-for="t in availableTypes" :key="t">
<option :value="t" x-text="typeLabel(t)"></option>
</template>
</select>
</div>
<!-- Name -->
<div>
<label for="intg-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
{{ _("integrations.name_label") }} <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="intg-name"
type="text"
x-model="form.name"
placeholder="{{ _('integrations.name_placeholder') }}"
required
maxlength="255"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
aria-required="true"
/>
</div>
<!-- ── Dynamic config fields per integration type ─────────────── -->
<!-- IMAP fields -->
<template x-if="form.integration_type === 'IMAP'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.imap_settings") }}</p>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label for="imap-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.host_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="imap-host" type="text" x-model="form.config.host" placeholder="imap.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="imap-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.port_label") }}</label>
<input id="imap-port" type="number" x-model.number="form.config.port" min="1" max="65535" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="imap-username" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="imap-username" type="text" x-model="form.config.username" placeholder="user@example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="imap-password" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="imap-password" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="flex flex-wrap items-center gap-4">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.use_ssl" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.use_ssl") }}
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.delete_after_process" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.delete_emails_label") }}
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.gmail_apply_labels" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.gmail_labels") }}
</label>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.show_password") }}
</label>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
{{ _("integrations.gmail_hint") }}
</p>
</div>
</template>
<!-- S3 fields -->
<template x-if="form.integration_type === 'S3'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.s3_settings") }}</p>
<div>
<label for="s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.bucket_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="s3-bucket" type="text" x-model="form.config.bucket" placeholder="my-bucket" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.region_label") }}</label>
<input id="s3-region" type="text" x-model="form.config.region" placeholder="us-east-1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_prefix_label") }}</label>
<input id="s3-prefix" type="text" x-model="form.config.folder_prefix" placeholder="documents/" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.endpoint_label") }} <span class="text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
<input id="s3-endpoint" type="url" x-model="form.config.endpoint_url" placeholder="https://s3.example.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="s3-key" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.access_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="s3-key" type="text" x-model="form.credentials.access_key_id" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="s3-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.secret_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="s3-secret" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.secret_access_key" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.show_secrets") }}
</label>
</div>
</template>
<!-- WebDAV / Nextcloud fields -->
<template x-if="form.integration_type === 'WEBDAV' || form.integration_type === 'NEXTCLOUD'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type === 'NEXTCLOUD' ? 'Nextcloud Settings' : 'WebDAV Settings'"></p>
<div>
<label for="webdav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.webdav_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="webdav-url" type="url" x-model="form.config.url" placeholder="https://cloud.example.com/remote.php/dav/files/user/" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="webdav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }}</label>
<input id="webdav-folder" type="text" x-model="form.config.folder" placeholder="/Documents" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="webdav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="webdav-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="webdav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="webdav-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.show_password") }}
</label>
</div>
</template>
<!-- FTP / SFTP fields -->
<template x-if="form.integration_type === 'FTP' || form.integration_type === 'SFTP'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
<div class="grid grid-cols-3 gap-3">
<div class="col-span-2">
<label for="ftp-host" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.host_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="ftp-host" type="text" x-model="form.config.host" placeholder="ftp.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="ftp-port" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.port_label") }}</label>
<input id="ftp-port" type="number" x-model.number="form.config.port" :placeholder="form.integration_type === 'SFTP' ? '22' : '21'" min="1" max="65535" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="ftp-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.remote_path_label") }}</label>
<input id="ftp-folder" type="text" x-model="form.config.folder" placeholder="/upload" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="ftp-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="ftp-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="ftp-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="ftp-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.show_password") }}
</label>
</div>
</template>
<!-- Dropbox / Google Drive / OneDrive (OAuth) fields -->
<template x-if="form.integration_type === 'DROPBOX' || form.integration_type === 'GOOGLE_DRIVE' || form.integration_type === 'ONEDRIVE'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="typeLabel(form.integration_type) + ' Settings'"></p>
<div>
<label for="oauth-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.oauth_folder_label") }} <span class="text-gray-400">{{ _("integrations.folder_optional") }}</span></label>
<input id="oauth-folder" type="text" x-model="form.config.folder" placeholder="/DocuElevate" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="bg-blue-50 dark:bg-blue-900/30 rounded-md p-3 text-sm text-blue-700 dark:text-blue-300">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
{{ _("integrations.oauth_hint") }}
</div>
</div>
</template>
<!-- Email (destination) fields -->
<template x-if="form.integration_type === 'EMAIL'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.email_settings") }}</p>
<div>
<label for="email-to" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.recipient_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="email-to" type="email" x-model="form.config.recipient" placeholder="archive@example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
</div>
</template>
<!-- Watch Folder fields -->
<template x-if="form.integration_type === 'WATCH_FOLDER'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.watch_folder_settings") }}</p>
<div>
<label for="wf-source-type" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.source_type_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<select id="wf-source-type" x-model="form.config.source_type" @change="onWatchFolderSourceTypeChange()" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true">
<option value="local">{{ _("integrations.source_local") }}</option>
<option value="s3">Amazon S3</option>
<option value="dropbox">Dropbox</option>
<option value="google_drive">Google Drive</option>
<option value="onedrive">OneDrive</option>
<option value="nextcloud">Nextcloud</option>
<option value="webdav">WebDAV</option>
</select>
</div>
<!-- Local filesystem fields -->
<template x-if="form.config.source_type === 'local'">
<div>
<label for="wf-path" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-path" type="text" x-model="form.config.folder_path" placeholder="/data/inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
</template>
<!-- S3 fields -->
<template x-if="form.config.source_type === 's3'">
<div class="space-y-3">
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.bucket_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-s3-bucket" type="text" x-model="form.config.bucket" placeholder="my-bucket" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-s3-region" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.region_label") }}</label>
<input id="wf-s3-region" type="text" x-model="form.config.region" placeholder="us-east-1" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
<div>
<label for="wf-s3-prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.s3_prefix_label") }}</label>
<input id="wf-s3-prefix" type="text" x-model="form.config.prefix" placeholder="inbox/" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div>
<label for="wf-s3-endpoint" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.endpoint_label") }} <span class="text-xs text-gray-400">{{ _("integrations.endpoint_optional") }}</span></label>
<input id="wf-s3-endpoint" type="text" x-model="form.config.endpoint_url" placeholder="https://s3.example.com" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-s3-ak" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.access_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-s3-ak" type="text" x-model="form.credentials.access_key_id" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-s3-sk" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.secret_key_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-s3-sk" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.secret_access_key" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
</div>
</template>
<!-- Dropbox fields -->
<template x-if="form.config.source_type === 'dropbox'">
<div class="space-y-3">
<div>
<label for="wf-dbx-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dbx-folder" type="text" x-model="form.config.folder_path" placeholder="/Inbox/Scanner" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-dbx-token" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Refresh Token <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dbx-token" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.refresh_token" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-dbx-key" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">App Key <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dbx-key" type="text" x-model="form.credentials.app_key" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-dbx-secret" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">App Secret <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dbx-secret" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.app_secret" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
</div>
</template>
<!-- Google Drive fields -->
<template x-if="form.config.source_type === 'google_drive'">
<div class="space-y-3">
<div>
<label for="wf-gd-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Folder ID <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-gd-folder" type="text" x-model="form.config.folder_id" placeholder="1ABCdef..." required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-gd-creds" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Service Account JSON <span class="text-red-500" aria-hidden="true">*</span></label>
<textarea id="wf-gd-creds" x-model="form.credentials.credentials_json" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" rows="3" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm font-mono"></textarea>
</div>
</div>
</template>
<!-- OneDrive fields -->
<template x-if="form.config.source_type === 'onedrive'">
<div class="space-y-3">
<div>
<label for="wf-od-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-od-folder" type="text" x-model="form.config.folder_path" placeholder="/Documents/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-od-token" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Refresh Token <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-od-token" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.refresh_token" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-od-cid" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-od-cid" type="text" x-model="form.credentials.client_id" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-od-cs" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-od-cs" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.client_secret" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
</div>
</template>
<!-- Nextcloud fields -->
<template x-if="form.config.source_type === 'nextcloud'">
<div class="space-y-3">
<div>
<label for="wf-nc-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.nextcloud_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-url" type="url" x-model="form.config.url" placeholder="https://cloud.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-nc-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-folder" type="text" x-model="form.config.folder_path" placeholder="/Documents/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-nc-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-nc-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-nc-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
</div>
</template>
<!-- WebDAV fields -->
<template x-if="form.config.source_type === 'webdav'">
<div class="space-y-3">
<div>
<label for="wf-dav-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.webdav_url_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-url" type="url" x-model="form.config.url" placeholder="https://webdav.example.com/dav/" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-dav-folder" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.folder_path_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-folder" type="text" x-model="form.config.folder_path" placeholder="/remote.php/webdav/Inbox" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label for="wf-dav-user" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.username_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-user" type="text" x-model="form.credentials.username" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="wf-dav-pass" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.password_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="wf-dav-pass" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.password" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
</div>
</div>
</template>
<!-- Common watch folder options -->
<div class="flex flex-wrap items-center gap-4 pt-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.config.delete_after_process" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.delete_files_label") }}
</label>
<template x-if="form.config.source_type !== 'local'">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.show_secrets") }}
</label>
</template>
</div>
</div>
</template>
<!-- Paperless fields -->
<template x-if="form.integration_type === 'PAPERLESS'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">{{ _("integrations.paperless_settings") }}</p>
<div>
<label for="paperless-url" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">URL <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="paperless-url" type="url" x-model="form.config.url" placeholder="https://paperless.example.com" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" aria-required="true" />
</div>
<div>
<label for="paperless-token" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">{{ _("integrations.api_token_label") }} <span class="text-red-500" aria-hidden="true">*</span></label>
<input id="paperless-token" :type="showPassword ? 'text' : 'password'" x-model="form.credentials.api_token" :placeholder="editingIntegration ? '(unchanged if blank)' : ''" :required="!editingIntegration" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.show_token") }}
</label>
</div>
</template>
<!-- Webhook explanation and sample snippets -->
<template x-if="form.integration_type === 'WEBHOOK'">
<div class="space-y-4 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-purple-500 uppercase tracking-wider">
<i class="fas fa-bolt mr-1" aria-hidden="true"></i> {{ _("integrations.webhook_heading") }}
</p>
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 class="text-sm font-semibold text-blue-800 dark:text-blue-200 mb-2">
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
{{ _("integrations.webhook_how_heading") }}
</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
{{ _("integrations.webhook_how_text") }}
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed mt-2">
{{ _("integrations.webhook_ideal_text") }}
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-750 rounded-lg p-4">
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mb-2">
<i class="fas fa-terminal mr-1" aria-hidden="true"></i>
{{ _("integrations.webhook_quick_start") }}
</h4>
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-decimal list-inside">
<li>
{% set api_tokens_link %}<a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">{{ _("nav.api_tokens") }}</a>{% endset %}
{{ _("integrations.webhook_step1", api_tokens_link=api_tokens_link) | safe }}
</li>
<li>{{ _("integrations.webhook_upload_with_token") }}</li>
</ol>
<div class="mt-3 relative">
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-xs overflow-x-auto font-mono leading-relaxed"><code>curl -X POST "<span x-text="window.location.origin"></span>/api/files/ui-upload" \
-H "Authorization: Bearer YOUR_API_TOKEN" \
-F "file=@/path/to/document.pdf"</code></pre>
<button
type="button"
@click="navigator.clipboard.writeText('curl -X POST \'' + window.location.origin + '/api/files/ui-upload\' \\\n -H \'Authorization: Bearer YOUR_API_TOKEN\' \\\n -F \'file=@/path/to/document.pdf\'')"
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:28px; min-width:28px;"
aria-label="Copy curl example to clipboard"
>
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
<h4 class="text-sm font-semibold text-gray-800 dark:text-gray-200 mt-4 mb-2">
<i class="fab fa-python mr-1" aria-hidden="true"></i>
Python Example
</h4>
<div class="relative">
<pre class="bg-gray-900 text-green-400 rounded-lg p-4 text-xs overflow-x-auto font-mono leading-relaxed"><code>import requests
response = requests.post(
"<span x-text="window.location.origin"></span>/api/files/ui-upload",
headers={"Authorization": "Bearer YOUR_API_TOKEN"},
files={"file": open("document.pdf", "rb")},
)
print(response.json())</code></pre>
<button
type="button"
@click="navigator.clipboard.writeText('import requests\n\nresponse = requests.post(\n \'' + window.location.origin + '/api/files/ui-upload\',\n headers={\'Authorization\': \'Bearer YOUR_API_TOKEN\'},\n files={\'file\': open(\'document.pdf\', \'rb\')},\n)\nprint(response.json())')"
class="absolute top-2 right-2 px-2 py-1 bg-gray-700 text-gray-300 rounded text-xs hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-indigo-500"
style="min-height:28px; min-width:28px;"
aria-label="Copy Python example to clipboard"
>
<i class="fas fa-copy" aria-hidden="true"></i>
</button>
</div>
</div>
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-3">
<p class="text-xs text-yellow-800 dark:text-yellow-300">
<i class="fas fa-shield-alt mr-1" aria-hidden="true"></i>
{{ _("integrations.webhook_security_tip") }}
</p>
</div>
</div>
</template>
<!-- Generic fallback for types without dedicated fields (excludes WEBHOOK) -->
<template x-if="form.integration_type && !hasFormFields(form.integration_type) && form.integration_type !== 'WEBHOOK'">
<div class="space-y-3 border-t border-gray-200 dark:border-gray-700 pt-3">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider" x-text="form.integration_type + ' Settings'"></p>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ _("integrations.generic_settings_hint") }}
</p>
</div>
</template>
<!-- Active toggle -->
<div class="flex items-center gap-2 pt-2">
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-200 cursor-pointer">
<input type="checkbox" x-model="form.is_active" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
{{ _("integrations.active_label") }}
</label>
</div>
<!-- Test Connection result -->
<template x-if="testResult">
<div
class="border-l-4 p-3 rounded text-sm"
:class="testResult.success ? 'bg-green-50 border-green-400 text-green-800 dark:bg-opacity-10' : 'bg-red-50 border-red-400 text-red-800 dark:bg-opacity-10'"
role="status"
>
<p class="font-medium" x-text="testResult.success ? '{{ _('integrations.connection_success') }}' : '{{ _('integrations.connection_failed') }}'"></p>
<p class="text-xs mt-0.5" x-text="testResult.message"></p>
</div>
</template>
<!-- Form error -->
<template x-if="formError">
<div class="bg-red-50 border border-red-200 text-red-700 p-3 rounded text-sm" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<span x-text="formError"></span>
</div>
</template>
<!-- Actions -->
<div class="flex justify-between items-center pt-2 border-t border-gray-200 dark:border-gray-700">
<button
type="button"
@click="testFromForm()"
:disabled="testing || !form.integration_type"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50"
style="min-height:40px;"
>
<template x-if="testing">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<template x-if="!testing">
<i class="fas fa-plug mr-2" aria-hidden="true"></i>
</template>
{{ _("common.test_connection") }}
</button>
<div class="flex gap-2">
<button
type="button"
@click="closeModal()"
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
style="min-height:40px;"
>
{{ _("common.cancel") }}
</button>
<button
type="submit"
:disabled="saving || !form.direction || !form.integration_type || !form.name"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:opacity-50"
style="min-height:40px;"
>
<template x-if="saving">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<span x-text="editingIntegration ? '{{ _('common.update') }}' : '{{ _('common.save') }}'"></span>
</button>
</div>
</div>
</form>
</div>
</div>
<!-- ── Delete confirmation modal ────────────────────────────────────────── -->
<div
x-show="deleteModalOpen"
x-transition
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4"
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
@keydown.escape.window="deleteModalOpen = false"
style="display:none;"
>
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-sm p-6" @click.stop>
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
{{ _("integrations.delete_confirm_title") }}
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
{{ _("integrations.delete_confirm_are_you_sure") }}
<strong x-text="integrationToDelete ? integrationToDelete.name : ''"></strong>?
{{ _("integrations.delete_confirm_undone") }}
</p>
<div class="flex justify-end gap-2">
<button
type="button"
@click="deleteModalOpen = false; integrationToDelete = null"
class="px-4 py-2 text-sm font-medium rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-gray-500"
style="min-height:40px;"
>
{{ _("common.cancel") }}
</button>
<button
type="button"
@click="deleteIntegration()"
:disabled="deleting"
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-red-600 hover:bg-red-700 text-white focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50"
style="min-height:40px;"
>
<template x-if="deleting">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
{{ _("common.delete") }}
</button>
</div>
</div>
</div>
</div>
<script>
function integrationsDashboard() {
const SOURCE_TYPES = ['IMAP', 'WATCH_FOLDER', 'WEBHOOK'];
const DEST_TYPES = ['S3', 'DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE', 'WEBDAV', 'NEXTCLOUD', 'FTP', 'SFTP', 'EMAIL', 'PAPERLESS', 'RCLONE'];
const TYPES_WITH_FORM_FIELDS = new Set(['IMAP', 'S3', 'WEBDAV', 'NEXTCLOUD', 'FTP', 'SFTP', 'DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE', 'EMAIL', 'WATCH_FOLDER', 'PAPERLESS']);
const OAUTH_TYPES = new Set(['DROPBOX', 'GOOGLE_DRIVE', 'ONEDRIVE']);
const TYPE_LABELS = {
IMAP: 'IMAP Email',
WATCH_FOLDER: 'Watch Folder',
WEBHOOK: 'Webhook',
S3: 'Amazon S3',
DROPBOX: 'Dropbox',
GOOGLE_DRIVE: 'Google Drive',
ONEDRIVE: 'OneDrive',
WEBDAV: 'WebDAV',
NEXTCLOUD: 'Nextcloud',
FTP: 'FTP',
SFTP: 'SFTP',
EMAIL: 'Email Forward',
PAPERLESS: 'Paperless NGX',
RCLONE: 'Rclone',
};
const TYPE_ICONS = {
IMAP: 'fa-inbox text-blue-500',
WATCH_FOLDER: 'fa-folder-open text-yellow-500',
WEBHOOK: 'fa-bolt text-purple-500',
S3: 'fa-cube text-orange-500',
DROPBOX: 'fa-dropbox text-blue-600',
GOOGLE_DRIVE: 'fa-cloud text-green-500',
ONEDRIVE: 'fa-cloud text-blue-400',
WEBDAV: 'fa-cloud text-gray-500',
NEXTCLOUD: 'fa-cloud text-blue-500',
FTP: 'fa-server text-gray-600',
SFTP: 'fa-server text-green-600',
EMAIL: 'fa-paper-plane text-indigo-500',
PAPERLESS: 'fa-file-pdf text-red-500',
RCLONE: 'fa-sync text-teal-500',
};
return {
integrations: [],
quota: {
dest_count: {{ dest_count }},
src_count: {{ src_count }},
max_destinations: {{ max_destinations | tojson }},
max_sources: {{ max_sources | tojson }},
can_add_destination: {{ 'true' if can_add_destination else 'false' }},
can_add_source: {{ 'true' if can_add_source else 'false' }},
tier_id: {{ tier_id | tojson }},
tier_name: {{ tier_name | tojson }},
},
loading: true,
alert: { show: false, type: '', title: '', message: '' },
// Modal state
modalOpen: false,
editingIntegration: null,
form: {
direction: '',
integration_type: '',
name: '',
config: {},
credentials: {},
is_active: true,
},
showPassword: false,
saving: false,
testing: false,
testResult: null,
formError: null,
// Delete state
deleteModalOpen: false,
integrationToDelete: null,
deleting: false,
// Test saved
testingId: null,
get sources() {
return this.integrations.filter(i => i.direction === 'SOURCE');
},
get destinations() {
return this.integrations.filter(i => i.direction === 'DESTINATION');
},
get canAddAny() {
return this.quota.can_add_destination || this.quota.can_add_source;
},
get availableTypes() {
if (this.form.direction === 'SOURCE') return SOURCE_TYPES;
if (this.form.direction === 'DESTINATION') return DEST_TYPES;
return [];
},
typeLabel(t) { return TYPE_LABELS[t] || t; },
typeIcon(t) { return TYPE_ICONS[t] || 'fa-plug text-gray-400'; },
hasFormFields(t) { return TYPES_WITH_FORM_FIELDS.has(t); },
_watchFolderOAuthSource(intg) {
if (intg.integration_type !== 'WATCH_FOLDER') return null;
const src = ((intg.config || {}).source_type || '').toLowerCase();
if (src === 'dropbox' || src === 'onedrive' || src === 'google_drive') return src;
return null;
},
isOAuthType(intg) {
// Direct OAuth types: DROPBOX, GOOGLE_DRIVE, ONEDRIVE
if (OAUTH_TYPES.has(intg.integration_type)) return true;
// WATCH_FOLDER with an OAuth-backed source type
return this._watchFolderOAuthSource(intg) !== null;
},
oauthLink(intg) {
const t = intg.integration_type;
if (t === 'DROPBOX') return '/dropbox-setup';
if (t === 'GOOGLE_DRIVE') return '/google-drive-setup';
if (t === 'ONEDRIVE') return '/onedrive-setup';
const src = this._watchFolderOAuthSource(intg);
if (src === 'dropbox') return '/dropbox-setup';
if (src === 'onedrive') return '/onedrive-setup';
if (src === 'google_drive') return '/google-drive-setup';
return '#';
},
async init() {
try {
const resp = await fetch('/api/integrations/', {
headers: { 'Accept': 'application/json' },
});
if (resp.ok) {
this.integrations = await resp.json();
}
} catch (err) {
console.error('Failed to load integrations', err);
} finally {
this.loading = false;
}
},
showAlert(type, title, message) {
this.alert = { show: true, type, title, message };
setTimeout(() => { this.alert.show = false; }, 5000);
},
formatDate(iso) {
if (!iso) return '';
try { return new Date(iso).toLocaleString(); }
catch (e) { return iso; }
},
onDirectionChange() {
this.form.integration_type = '';
this.form.config = {};
this.form.credentials = {};
this.testResult = null;
},
onTypeChange() {
this.form.config = {};
this.form.credentials = {};
this.testResult = null;
// Set sensible defaults
if (this.form.integration_type === 'IMAP') {
this.form.config = { host: '', port: 993, username: '', use_ssl: true, delete_after_process: false, gmail_apply_labels: true };
this.form.credentials = { password: '' };
} else if (this.form.integration_type === 'S3') {
this.form.config = { bucket: '', region: 'us-east-1', folder_prefix: '', endpoint_url: '' };
this.form.credentials = { access_key_id: '', secret_access_key: '' };
} else if (this.form.integration_type === 'WEBDAV' || this.form.integration_type === 'NEXTCLOUD') {
this.form.config = { url: '', folder: '' };
this.form.credentials = { username: '', password: '' };
} else if (this.form.integration_type === 'FTP' || this.form.integration_type === 'SFTP') {
this.form.config = { host: '', port: this.form.integration_type === 'SFTP' ? 22 : 21, folder: '' };
this.form.credentials = { username: '', password: '' };
} else if (this.form.integration_type === 'DROPBOX' || this.form.integration_type === 'GOOGLE_DRIVE' || this.form.integration_type === 'ONEDRIVE') {
this.form.config = { folder: '' };
this.form.credentials = {};
} else if (this.form.integration_type === 'EMAIL') {
this.form.config = { recipient: '' };
this.form.credentials = {};
} else if (this.form.integration_type === 'WATCH_FOLDER') {
this.form.config = { source_type: 'local', folder_path: '', delete_after_process: false };
this.form.credentials = {};
} else if (this.form.integration_type === 'PAPERLESS') {
this.form.config = { url: '' };
this.form.credentials = { api_token: '' };
}
},
onWatchFolderSourceTypeChange() {
const st = this.form.config.source_type;
const dap = this.form.config.delete_after_process || false;
this.form.credentials = {};
if (st === 'local') {
this.form.config = { source_type: st, folder_path: '', delete_after_process: dap };
} else if (st === 's3') {
this.form.config = { source_type: st, bucket: '', region: 'us-east-1', prefix: '', endpoint_url: '', delete_after_process: dap };
this.form.credentials = { access_key_id: '', secret_access_key: '' };
} else if (st === 'dropbox') {
this.form.config = { source_type: st, folder_path: '', delete_after_process: dap };
this.form.credentials = { refresh_token: '', app_key: '', app_secret: '' };
} else if (st === 'google_drive') {
this.form.config = { source_type: st, folder_id: '', delete_after_process: dap };
this.form.credentials = { credentials_json: '' };
} else if (st === 'onedrive') {
this.form.config = { source_type: st, folder_path: '', delete_after_process: dap };
this.form.credentials = { refresh_token: '', client_id: '', client_secret: '' };
} else if (st === 'nextcloud') {
this.form.config = { source_type: st, url: '', folder_path: '', delete_after_process: dap };
this.form.credentials = { username: '', password: '' };
} else if (st === 'webdav') {
this.form.config = { source_type: st, url: '', folder_path: '', delete_after_process: dap };
this.form.credentials = { username: '', password: '' };
}
},
openCreateModal() {
this.editingIntegration = null;
this.form = {
direction: '',
integration_type: '',
name: '',
config: {},
credentials: {},
is_active: true,
};
this.showPassword = false;
this.testResult = null;
this.formError = null;
this.modalOpen = true;
},
openEditModal(intg) {
this.editingIntegration = intg;
this.form = {
direction: intg.direction,
integration_type: intg.integration_type,
name: intg.name,
config: intg.config ? JSON.parse(JSON.stringify(intg.config)) : {},
credentials: {},
is_active: intg.is_active,
};
this.showPassword = false;
this.testResult = null;
this.formError = null;
this.modalOpen = true;
},
closeModal() {
this.modalOpen = false;
this.editingIntegration = null;
this.testResult = null;
this.formError = null;
},
_buildPayload() {
const payload = {
direction: this.form.direction,
integration_type: this.form.integration_type,
name: this.form.name,
is_active: this.form.is_active,
};
// Include config if non-empty
const cfg = this.form.config;
if (cfg && Object.keys(cfg).length > 0) {
payload.config = cfg;
}
// Include credentials only if user entered values
const creds = this.form.credentials;
if (creds && Object.keys(creds).length > 0) {
const hasValue = Object.values(creds).some(v => v !== '' && v !== null && v !== undefined);
if (hasValue) payload.credentials = creds;
}
return payload;
},
async saveIntegration() {
this.formError = null;
this.saving = true;
try {
const payload = this._buildPayload();
let resp;
if (this.editingIntegration) {
// For updates, only send updatable fields
const update = { name: payload.name, is_active: payload.is_active };
if (payload.config) update.config = payload.config;
if (payload.credentials) update.credentials = payload.credentials;
resp = await fetch(`/api/integrations/${this.editingIntegration.id}`, {
method: 'PUT',
headers: authHeaders(true),
body: JSON.stringify(update),
});
} else {
resp = await fetch('/api/integrations/', {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify(payload),
});
}
const data = await resp.json();
if (!resp.ok) {
this.formError = data.detail || 'Failed to save integration.';
return;
}
// Update local list
if (this.editingIntegration) {
const idx = this.integrations.findIndex(i => i.id === data.id);
if (idx !== -1) this.integrations.splice(idx, 1, data);
} else {
this.integrations.push(data);
this._recalcQuota();
}
this.closeModal();
this.showAlert('success', 'Saved', this.editingIntegration ? 'Integration updated.' : 'Integration added successfully.');
} catch (err) {
this.formError = `Network error: ${err.message || 'Unknown error'}. Please try again.`;
} finally {
this.saving = false;
}
},
async testFromForm() {
this.testing = true;
this.testResult = null;
try {
const payload = {
integration_type: this.form.integration_type,
config: this.form.config,
credentials: Object.keys(this.form.credentials).length > 0 ? this.form.credentials : null,
};
const resp = await fetch('/api/integrations/test', {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify(payload),
});
this.testResult = await resp.json();
} catch (err) {
this.testResult = { success: false, message: `Network error: ${err.message || 'Unknown error'}` };
} finally {
this.testing = false;
}
},
async testSavedIntegration(intg) {
this.testingId = intg.id;
try {
// Retrieve saved credentials to test
const credsResp = await fetch(`/api/integrations/${intg.id}/credentials`);
if (!credsResp.ok) {
this.showAlert('error', 'Test Failed', 'Could not retrieve saved credentials for testing.');
return;
}
const creds = await credsResp.json();
const resp = await fetch('/api/integrations/test', {
method: 'POST',
headers: authHeaders(true),
body: JSON.stringify({
integration_type: intg.integration_type,
config: intg.config,
credentials: creds,
}),
});
const data = await resp.json();
if (data.success) {
this.showAlert('success', `${intg.name}: Connection OK`, data.message);
} else {
this.showAlert('error', `${intg.name}: Connection Failed`, data.message);
}
} catch (err) {
this.showAlert('error', 'Test Failed', `Network error: ${err.message || 'Unknown error'}`);
} finally {
this.testingId = null;
}
},
confirmDelete(intg) {
this.integrationToDelete = intg;
this.deleteModalOpen = true;
},
async deleteIntegration() {
if (!this.integrationToDelete) return;
this.deleting = true;
try {
const resp = await fetch(`/api/integrations/${this.integrationToDelete.id}`, {
method: 'DELETE',
headers: authHeaders(false),
});
if (resp.ok || resp.status === 204) {
this.integrations = this.integrations.filter(i => i.id !== this.integrationToDelete.id);
this._recalcQuota();
this.showAlert('success', 'Deleted', `"${this.integrationToDelete.name}" has been removed.`);
this.deleteModalOpen = false;
this.integrationToDelete = null;
} else {
const data = await resp.json();
this.showAlert('error', 'Delete Failed', data.detail || 'Could not delete integration.');
}
} catch (err) {
this.showAlert('error', 'Delete Failed', `Network error: ${err.message || 'Unknown error'}`);
} finally {
this.deleting = false;
}
},
_recalcQuota() {
this.quota.dest_count = this.integrations.filter(i => i.direction === 'DESTINATION').length;
this.quota.src_count = this.integrations.filter(i => i.direction === 'SOURCE' && i.integration_type === 'IMAP').length;
if (this.quota.max_destinations !== null) {
this.quota.can_add_destination = this.quota.dest_count < this.quota.max_destinations;
}
if (this.quota.max_sources !== null && this.quota.max_sources > 0) {
this.quota.can_add_source = this.quota.src_count < this.quota.max_sources;
}
},
};
}
function getCsrfToken() {
const meta = document.querySelector('meta[name="csrf-token"]');
if (meta) return meta.getAttribute('content');
return '';
}
function authHeaders(json) {
const h = { 'X-CSRF-Token': getCsrfToken() };
if (json) h['Content-Type'] = 'application/json';
return h;
}
</script>
{% endblock %}