Files
gh-christianlouis-docuelevate/frontend/templates/integrations_dashboard.html
T
copilot-swe-agent[bot] ed01952610 feat(ui): replace manual credential fields with OAuth wizard flow for watch folder sources
Watch folders using Dropbox, Google Drive, or OneDrive now show only the
folder path field and an info box directing users to use the Authorize
button after saving — matching the destination integration pattern.
Manual credential fields (refresh token, app key, app secret, etc.)
have been removed for these OAuth-backed source types.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-20 13:12:39 +00:00

1458 lines
83 KiB
HTML
Raw 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 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
</h1>
<p class="text-gray-500 dark:text-gray-400 text-sm mt-1">
Manage your ingestion sources and storage destinations in one place.
</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> Add Integration
</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">
Mailbox Sources:
<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"> / unlimited</span>
</template>
<template x-if="quota.max_sources === 0">
<span class="text-gray-400 ml-1">(not available)</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Plan: <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> 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">
Storage Destinations:
<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"> / unlimited</span>
</template>
</p>
<p class="text-xs text-gray-400 dark:text-gray-500 mt-0.5">
Plan: <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> 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>Loading integrations…</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">No integrations configured</h2>
<p class="text-gray-500 dark:text-gray-400 mb-6 max-w-md mx-auto">
Add your first integration to connect ingestion sources (like IMAP) and storage destinations (like S3, Dropbox, or Google Drive).
</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> Add Your First Integration
</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> Sources
</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 ? 'Active' : '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 ? 'Re-Authorize' : 'Authorize'"></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>
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>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>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> Destinations
</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 ? 'Active' : '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 ? 'Re-Authorize' : 'Authorize'"></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>
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>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>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 ? 'Edit Integration' : 'Add Integration'"
></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="Close modal"
>
<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">
Direction <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="">— Select —</option>
<option value="SOURCE">Source (ingestion)</option>
<option value="DESTINATION">Destination (storage)</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">
Integration Type <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="">— Select direction first —</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">
Label <span class="text-red-500" aria-hidden="true">*</span>
</label>
<input
id="intg-name"
type="text"
x-model="form.name"
placeholder="e.g. Work Gmail, S3 Archive"
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">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">Host <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">Port</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">Username <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">Password <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" />
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" />
Delete emails after processing
</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" />
Apply Gmail labels &amp; star
</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" />
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>
Gmail labels &amp; star: when enabled, processed emails are starred and tagged with an "Ingested" label in Gmail. Only applies to Gmail servers.
</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">S3 Settings</p>
<div>
<label for="s3-bucket" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Bucket <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">Region</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">Folder Prefix</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">Endpoint URL <span class="text-gray-400">(optional, for S3-compatible)</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">Access Key ID <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">Secret Access Key <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" />
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">URL <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">Folder Path</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">Username <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">Password <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" />
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">Host <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">Port</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">Remote Path</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">Username <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">Password <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" />
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">Folder <span class="text-gray-400">(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>
Save this integration first, then use the <strong>Authorize</strong> button to complete the OAuth flow.
Your credentials will be stored securely in your personal integration record.
</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">Email Forward Settings</p>
<div>
<label for="email-to" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">Recipient Email <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">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">Source Type <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">Local Filesystem</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">Folder Path <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">Bucket <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">Region</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">Prefix (folder path)</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">Endpoint URL <span class="text-xs text-gray-400">(optional, for S3-compatible)</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">Access Key ID <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">Secret Access Key <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 (OAuth wizard flow) -->
<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">Folder Path <span class="text-gray-400">(optional)</span></label>
<input id="wf-dbx-folder" type="text" x-model="form.config.folder_path" placeholder="/Inbox/Scanner" 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>
Save this integration first, then use the <strong>Authorize</strong> button to complete the OAuth flow.
Your credentials will be stored securely in your personal integration record.
</div>
</div>
</template>
<!-- Google Drive fields (OAuth wizard flow) -->
<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-gray-400">(optional)</span></label>
<input id="wf-gd-folder" type="text" x-model="form.config.folder_id" placeholder="1ABCdef..." 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>
Save this integration first, then use the <strong>Authorize</strong> button to complete the OAuth flow.
Your credentials will be stored securely in your personal integration record.
</div>
</div>
</template>
<!-- OneDrive fields (OAuth wizard flow) -->
<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">Folder Path <span class="text-gray-400">(optional)</span></label>
<input id="wf-od-folder" type="text" x-model="form.config.folder_path" placeholder="/Documents/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 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>
Save this integration first, then use the <strong>Authorize</strong> button to complete the OAuth flow.
Your credentials will be stored securely in your personal integration record.
</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">Nextcloud URL <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">Folder Path <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">Username <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">Password <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">WebDAV URL <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">Folder Path <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">Username <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">Password <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" />
Delete files after processing
</label>
<template x-if="form.config.source_type !== 'local' && form.config.source_type !== 'dropbox' && form.config.source_type !== 'google_drive' && form.config.source_type !== 'onedrive'">
<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" />
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">Paperless NGX 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">API Token <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" />
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> Webhook Ingestion
</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>
How Webhook Ingestion Works
</h4>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed">
A webhook integration allows external systems to push documents directly into DocuElevate
via the REST API. Instead of DocuElevate polling for new files (like IMAP), <strong>your
application sends files to DocuElevate</strong> using an HTTP request with an API token for
authentication.
</p>
<p class="text-sm text-blue-700 dark:text-blue-300 leading-relaxed mt-2">
This is ideal for <strong>CI/CD pipelines</strong>, <strong>automation scripts</strong>,
<strong>scanner integrations</strong>, or any system that generates documents and needs to
send them for processing.
</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>
Quick Start
</h4>
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-2 list-decimal list-inside">
<li>
Go to <a href="/api-tokens" class="text-indigo-600 hover:underline font-medium">API Tokens</a>
and create a personal token.
</li>
<li>Use the token to upload documents via the API:</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>
<strong>Security tip:</strong> Create a dedicated API token for each integration and
revoke it immediately if compromised. Tokens can be managed on the
<a href="/api-tokens" class="underline font-medium">API Tokens</a> page.
</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">
Configuration for this integration type can be provided as JSON after creation via the API.
</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" />
Active
</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 ? 'Connection successful' : '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>
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;"
>
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 ? 'Update' : '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">
Delete Integration?
</h2>
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
Are you sure you want to delete
<strong x-text="integrationToDelete ? integrationToDelete.name : ''"></strong>?
This action cannot be 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;"
>
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>
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 = {};
} else if (st === 'google_drive') {
this.form.config = { source_type: st, folder_id: '', delete_after_process: dap };
this.form.credentials = {};
} else if (st === 'onedrive') {
this.form.config = { source_type: st, folder_path: '', delete_after_process: dap };
this.form.credentials = {};
} 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 %}