e27bacee3b
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
1129 lines
58 KiB
HTML
1129 lines
58 KiB
HTML
{% 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">
|
||
<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">
|
||
<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 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="showPassword" class="rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" />
|
||
Show password
|
||
</label>
|
||
</div>
|
||
</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>
|
||
OAuth tokens can be configured via the dedicated
|
||
<a :href="oauthLink(form.integration_type)" class="underline hover:text-blue-900 dark:hover:text-blue-100" x-text="typeLabel(form.integration_type) + ' setup page'"></a>.
|
||
Once authorised, the token is stored automatically.
|
||
</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-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.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>
|
||
</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>
|
||
|
||
<!-- Generic fallback for types without dedicated fields -->
|
||
<template x-if="form.integration_type && !hasFormFields(form.integration_type)">
|
||
<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 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); },
|
||
|
||
oauthLink(t) {
|
||
if (t === 'DROPBOX') return '/dropbox';
|
||
if (t === 'GOOGLE_DRIVE') return '/google-drive';
|
||
if (t === 'ONEDRIVE') return '/onedrive';
|
||
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 };
|
||
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 = { path: '' };
|
||
this.form.credentials = {};
|
||
} else if (this.form.integration_type === 'PAPERLESS') {
|
||
this.form.config = { url: '' };
|
||
this.form.credentials = { api_token: '' };
|
||
}
|
||
},
|
||
|
||
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 %}
|