Merge branch 'main' into copilot/refactor-template-localization
This commit is contained in:
@@ -168,15 +168,15 @@
|
||||
<a href="/admin/scheduled-jobs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-clock w-4 mr-2 text-indigo-500" aria-hidden="true"></i> {{ _("nav.scheduled_jobs") }}
|
||||
</a>
|
||||
<a href="/admin/compliance" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-check-double w-4 mr-2 text-green-500" aria-hidden="true"></i> Compliance
|
||||
</a>
|
||||
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
||||
</a>
|
||||
<a href="/admin/audit-logs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-shield-halved w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Audit Logs
|
||||
</a>
|
||||
<a href="/admin/audit-logs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-shield-halved w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Audit Logs
|
||||
</a>
|
||||
<a href="/status" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100"
|
||||
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
|
||||
<i class="fas fa-circle-dot w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.status") }}
|
||||
@@ -389,6 +389,9 @@
|
||||
<a href="/admin/scheduled-jobs" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-clock mr-2 text-indigo-500" aria-hidden="true"></i> {{ _("nav.scheduled_jobs") }}
|
||||
</a>
|
||||
<a href="/admin/compliance" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-check-double mr-2 text-green-500" aria-hidden="true"></i> Compliance
|
||||
</a>
|
||||
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
|
||||
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
|
||||
</a>
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Compliance Templates - DocuElevate{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6" x-data="complianceApp()">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start gap-4 mb-4">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold mb-1">Compliance Templates</h1>
|
||||
<p class="text-gray-500 text-sm">
|
||||
Pre-built compliance configurations for GDPR, HIPAA, and SOC 2.
|
||||
Apply templates with one click to align your instance with regulatory requirements.
|
||||
</p>
|
||||
</div>
|
||||
<button @click="refreshAll()"
|
||||
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
:disabled="loading"
|
||||
aria-label="Refresh compliance status">
|
||||
<i class="fas fa-sync-alt mr-2" :class="loading ? 'animate-spin' : ''" aria-hidden="true"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Overall Status Banner -->
|
||||
<div class="mb-6 rounded-lg p-5 shadow"
|
||||
:class="{
|
||||
'bg-green-50 border border-green-200': summary.overall_status === 'compliant',
|
||||
'bg-yellow-50 border border-yellow-200': summary.overall_status === 'partial',
|
||||
'bg-red-50 border border-red-200': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
|
||||
}">
|
||||
<div class="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="inline-flex items-center justify-center h-12 w-12 rounded-full text-xl"
|
||||
:class="{
|
||||
'bg-green-100 text-green-600': summary.overall_status === 'compliant',
|
||||
'bg-yellow-100 text-yellow-600': summary.overall_status === 'partial',
|
||||
'bg-red-100 text-red-600': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
|
||||
}">
|
||||
<i :class="{
|
||||
'fas fa-check-circle': summary.overall_status === 'compliant',
|
||||
'fas fa-exclamation-triangle': summary.overall_status === 'partial',
|
||||
'fas fa-times-circle': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
|
||||
}" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold"
|
||||
:class="{
|
||||
'text-green-800': summary.overall_status === 'compliant',
|
||||
'text-yellow-800': summary.overall_status === 'partial',
|
||||
'text-red-800': summary.overall_status === 'non_compliant' || summary.overall_status === 'unknown'
|
||||
}">
|
||||
<span x-text="summary.overall_status === 'compliant' ? 'All Checks Passing' :
|
||||
summary.overall_status === 'partial' ? 'Partially Compliant' :
|
||||
'Non-Compliant'"></span>
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600">
|
||||
<span x-text="summary.total_passed"></span> of <span x-text="summary.total_checks"></span> checks passing across all templates
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-4 text-center">
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-green-700" x-text="summary.total_passed"></div>
|
||||
<div class="text-xs text-gray-500">Passed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-red-700" x-text="summary.total_failed"></div>
|
||||
<div class="text-xs text-gray-500">Failed</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="text-2xl font-bold text-gray-700" x-text="summary.total_checks"></div>
|
||||
<div class="text-xs text-gray-500">Total</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Alert Messages -->
|
||||
<div x-show="alertMessage" x-cloak
|
||||
class="mb-4 rounded-lg p-4 flex items-center gap-3"
|
||||
:class="alertType === 'success' ? 'bg-green-50 border border-green-200 text-green-800' :
|
||||
'bg-red-50 border border-red-200 text-red-800'"
|
||||
role="alert">
|
||||
<i :class="alertType === 'success' ? 'fas fa-check-circle text-green-500' : 'fas fa-exclamation-circle text-red-500'" aria-hidden="true"></i>
|
||||
<span x-text="alertMessage"></span>
|
||||
<button @click="alertMessage = ''" class="ml-auto text-gray-400 hover:text-gray-600" aria-label="Dismiss alert">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Template Cards -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
||||
<template x-for="tmpl in templates" :key="tmpl.name">
|
||||
<div class="bg-white rounded-lg shadow-md overflow-hidden">
|
||||
<!-- Card Header -->
|
||||
<div class="p-5 border-b"
|
||||
:class="{
|
||||
'border-green-200 bg-green-50': tmpl._status === 'compliant',
|
||||
'border-yellow-200 bg-yellow-50': tmpl._status === 'partial',
|
||||
'border-gray-200': tmpl._status === 'not_applied' || tmpl._status === 'non_compliant'
|
||||
}">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-lg font-bold text-gray-800" x-text="tmpl.display_name"></h3>
|
||||
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-semibold"
|
||||
:class="{
|
||||
'bg-green-100 text-green-800': tmpl._status === 'compliant',
|
||||
'bg-yellow-100 text-yellow-800': tmpl._status === 'partial',
|
||||
'bg-red-100 text-red-800': tmpl._status === 'non_compliant',
|
||||
'bg-gray-100 text-gray-600': tmpl._status === 'not_applied'
|
||||
}">
|
||||
<span x-text="tmpl._status === 'compliant' ? 'Compliant' :
|
||||
tmpl._status === 'partial' ? 'Partial' :
|
||||
tmpl._status === 'non_compliant' ? 'Non-Compliant' :
|
||||
'Not Applied'"></span>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-600" x-text="tmpl.description"></p>
|
||||
</div>
|
||||
|
||||
<!-- Check Results (when expanded) -->
|
||||
<div class="p-5" x-show="tmpl._expanded" x-collapse>
|
||||
<h4 class="text-sm font-semibold text-gray-700 mb-3">
|
||||
<i class="fas fa-clipboard-check mr-1" aria-hidden="true"></i>
|
||||
Compliance Checks (<span x-text="tmpl._statusData ? tmpl._statusData.passed : '?'"></span>/<span x-text="tmpl._statusData ? tmpl._statusData.total : '?'"></span>)
|
||||
</h4>
|
||||
<ul class="space-y-2">
|
||||
<template x-for="check in (tmpl._statusData ? tmpl._statusData.check_results : [])" :key="check.key">
|
||||
<li class="flex items-start gap-2 text-sm">
|
||||
<span class="mt-0.5 flex-shrink-0">
|
||||
<i :class="check.passing ? 'fas fa-check-circle text-green-500' : 'fas fa-times-circle text-red-500'" aria-hidden="true"></i>
|
||||
</span>
|
||||
<div>
|
||||
<span class="font-medium" x-text="check.label"></span>
|
||||
<p class="text-xs text-gray-500" x-text="check.description"></p>
|
||||
<p class="text-xs mt-0.5" x-show="!check.passing">
|
||||
<span class="text-red-600">Current: <code x-text="check.actual"></code></span>
|
||||
<span class="text-gray-400 mx-1">→</span>
|
||||
<span class="text-green-600">Expected: <code x-text="check.expected"></code></span>
|
||||
</p>
|
||||
</div>
|
||||
</li>
|
||||
</template>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<!-- Card Footer -->
|
||||
<div class="px-5 py-4 bg-gray-50 border-t flex items-center justify-between gap-2">
|
||||
<button @click="toggleExpand(tmpl)"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
:aria-expanded="tmpl._expanded"
|
||||
:aria-label="'Toggle details for ' + tmpl.display_name">
|
||||
<i class="fas mr-1" :class="tmpl._expanded ? 'fa-chevron-up' : 'fa-chevron-down'" aria-hidden="true"></i>
|
||||
<span x-text="tmpl._expanded ? 'Hide Details' : 'Show Details'"></span>
|
||||
</button>
|
||||
<button @click="applyTemplate(tmpl.name)"
|
||||
:disabled="tmpl._applying"
|
||||
:aria-busy="tmpl._applying"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 min-h-[44px] min-w-[44px]"
|
||||
:class="tmpl._applying ? 'bg-gray-400 cursor-not-allowed' : 'bg-blue-600 hover:bg-blue-700'"
|
||||
:aria-label="'Apply ' + tmpl.display_name + ' template'"
|
||||
aria-live="polite">>
|
||||
<i class="fas mr-1.5" :class="tmpl._applying ? 'fa-spinner animate-spin' : 'fa-bolt'" aria-hidden="true"></i>
|
||||
<span x-text="tmpl._applying ? 'Applying…' : 'Apply Template'"></span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Applied info -->
|
||||
<div x-show="tmpl.applied_at" class="px-5 py-2 bg-gray-50 border-t text-xs text-gray-500">
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>
|
||||
Last applied: <span x-text="tmpl.applied_at ? new Date(tmpl.applied_at).toLocaleString() : 'Never'"></span>
|
||||
<span x-show="tmpl.applied_by"> by <span x-text="tmpl.applied_by"></span></span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div x-show="!loading && templates.length === 0" class="text-center py-12 text-gray-500">
|
||||
<i class="fas fa-shield-alt text-4xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-lg font-medium">No compliance templates available</p>
|
||||
<p class="text-sm">Templates will appear here after seeding.</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading Spinner -->
|
||||
<div x-show="loading && templates.length === 0" class="text-center py-12">
|
||||
<i class="fas fa-spinner animate-spin text-3xl text-blue-500" aria-hidden="true"></i>
|
||||
<p class="text-gray-500 mt-2">Loading compliance templates…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function complianceApp() {
|
||||
return {
|
||||
templates: [],
|
||||
summary: { overall_status: 'unknown', total_checks: 0, total_passed: 0, total_failed: 0 },
|
||||
loading: true,
|
||||
alertMessage: '',
|
||||
alertType: 'success',
|
||||
|
||||
async init() {
|
||||
await this.refreshAll();
|
||||
},
|
||||
|
||||
async refreshAll() {
|
||||
this.loading = true;
|
||||
try {
|
||||
const [templatesRes, summaryRes] = await Promise.all([
|
||||
fetch('/api/compliance/templates'),
|
||||
fetch('/api/compliance/summary')
|
||||
]);
|
||||
|
||||
if (templatesRes.ok) {
|
||||
const data = await templatesRes.json();
|
||||
this.templates = data.map(t => ({
|
||||
...t,
|
||||
_expanded: false,
|
||||
_applying: false,
|
||||
_status: t.status || 'not_applied',
|
||||
_statusData: null
|
||||
}));
|
||||
|
||||
// Fetch status for each template
|
||||
for (const tmpl of this.templates) {
|
||||
this.fetchTemplateStatus(tmpl);
|
||||
}
|
||||
}
|
||||
|
||||
if (summaryRes.ok) {
|
||||
this.summary = await summaryRes.json();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load compliance data:', err);
|
||||
this.showAlert('error', 'Failed to load compliance data. Please try again.');
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
},
|
||||
|
||||
async fetchTemplateStatus(tmpl) {
|
||||
try {
|
||||
const res = await fetch(`/api/compliance/templates/${tmpl.name}/status`);
|
||||
if (res.ok) {
|
||||
tmpl._statusData = await res.json();
|
||||
tmpl._status = tmpl._statusData.status;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to fetch status for ${tmpl.name}:`, err);
|
||||
}
|
||||
},
|
||||
|
||||
async toggleExpand(tmpl) {
|
||||
tmpl._expanded = !tmpl._expanded;
|
||||
if (tmpl._expanded && !tmpl._statusData) {
|
||||
await this.fetchTemplateStatus(tmpl);
|
||||
}
|
||||
},
|
||||
|
||||
async applyTemplate(name) {
|
||||
const tmpl = this.templates.find(t => t.name === name);
|
||||
if (!tmpl) return;
|
||||
|
||||
if (!confirm(`Apply the ${tmpl.display_name} compliance template?\n\nThis will update your application settings to meet ${name.toUpperCase()} requirements.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
tmpl._applying = true;
|
||||
this.alertMessage = '';
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/compliance/templates/${name}/apply`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok && data.success) {
|
||||
this.showAlert('success', `${tmpl.display_name} template applied successfully.`);
|
||||
await this.refreshAll();
|
||||
} else {
|
||||
this.showAlert('error', data.detail || data.error || 'Failed to apply template.');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`Failed to apply ${name}:`, err);
|
||||
this.showAlert('error', 'Network error. Please try again.');
|
||||
} finally {
|
||||
tmpl._applying = false;
|
||||
}
|
||||
},
|
||||
|
||||
showAlert(type, message) {
|
||||
this.alertType = type;
|
||||
this.alertMessage = message;
|
||||
if (type === 'success') {
|
||||
setTimeout(() => { this.alertMessage = ''; }, 5000);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -524,6 +524,7 @@
|
||||
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
|
||||
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
|
||||
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
|
||||
<option value="icloud" {% if storage_provider == "icloud" %}selected{% endif %}>iCloud Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -172,6 +172,11 @@
|
||||
<i class="fas fa-trash-alt mr-1" aria-hidden="true"></i>Delete after process
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="acct.profile_id">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded bg-blue-50 text-blue-700">
|
||||
<i class="fas fa-filter mr-1" aria-hidden="true"></i><span x-text="profileName(acct.profile_id)"></span>
|
||||
</span>
|
||||
</template>
|
||||
<template x-if="acct.last_checked_at">
|
||||
<span class="text-gray-400" :title="acct.last_checked_at">
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>Last polled: <span x-text="formatDate(acct.last_checked_at)"></span>
|
||||
@@ -386,6 +391,49 @@
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- Attachment filter -->
|
||||
<div>
|
||||
<label for="acct-profile" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||||
Ingestion Profile
|
||||
</label>
|
||||
<div class="flex gap-2 items-center">
|
||||
<select
|
||||
id="acct-profile"
|
||||
x-model.number="form.profile_id"
|
||||
class="flex-1 px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||||
aria-describedby="acct-profile-hint"
|
||||
>
|
||||
<option :value="null">Use global default</option>
|
||||
<template x-for="profile in profiles" :key="profile.id">
|
||||
<option :value="profile.id" x-text="profile.name + (profile.is_builtin ? '' : ' (custom)')"></option>
|
||||
</template>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
@click="openProfileModal(null)"
|
||||
class="inline-flex items-center px-3 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"
|
||||
style="min-height:40px;"
|
||||
title="Create new profile"
|
||||
aria-label="Create new ingestion profile"
|
||||
>
|
||||
<i class="fas fa-plus" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<p id="acct-profile-hint" class="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Controls which attachment types are ingested. Leave blank to use the global default (documents only).
|
||||
<button type="button" @click="showProfilesSection = !showProfilesSection" class="underline hover:text-blue-600 focus:outline-none">
|
||||
Manage profiles
|
||||
</button>
|
||||
</p>
|
||||
<!-- Inline selected profile summary -->
|
||||
<template x-if="form.profile_id">
|
||||
<div class="mt-2 text-xs bg-blue-50 dark:bg-blue-900/20 rounded px-2 py-1.5 text-blue-800 dark:text-blue-200">
|
||||
<strong x-text="profileName(form.profile_id)"></strong>:
|
||||
<span x-text="profileCategorySummary(form.profile_id)"></span>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Test connection result -->
|
||||
<template x-if="testResult">
|
||||
<div
|
||||
@@ -494,10 +542,277 @@
|
||||
|
||||
</div>
|
||||
|
||||
<!-- ── Ingestion Profiles panel ─────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="showProfilesSection"
|
||||
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="profiles-panel-title"
|
||||
@keydown.escape.window="showProfilesSection = false"
|
||||
style="display:none;"
|
||||
>
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-2xl overflow-y-auto max-h-[90vh]" @click.stop>
|
||||
<div class="px-6 pt-6 pb-2 flex items-center justify-between">
|
||||
<h2 id="profiles-panel-title" class="text-lg font-semibold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-filter text-blue-500" aria-hidden="true"></i>
|
||||
Ingestion Profiles
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="showProfilesSection = false"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
aria-label="Close profiles panel"
|
||||
>
|
||||
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-6 pb-6 pt-2">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
Profiles define which attachment types are ingested from emails. Assign a profile to each IMAP account for fine-grained control.
|
||||
</p>
|
||||
|
||||
<!-- Profile list -->
|
||||
<div class="space-y-3 mb-4">
|
||||
<template x-for="profile in profiles" :key="profile.id">
|
||||
<div class="border border-gray-100 dark:border-gray-700 rounded-lg p-4 flex items-start gap-3">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span class="font-medium text-sm text-gray-900 dark:text-white" x-text="profile.name"></span>
|
||||
<template x-if="profile.is_builtin">
|
||||
<span class="text-xs px-1.5 py-0.5 rounded bg-gray-100 dark:bg-gray-700 text-gray-500">built-in</span>
|
||||
</template>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-0.5" x-text="profile.description || ''"></p>
|
||||
<!-- Category badges -->
|
||||
<div class="flex flex-wrap gap-1 mt-2">
|
||||
<template x-for="cat in profile.allowed_categories" :key="cat">
|
||||
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300">
|
||||
<span x-text="categoryLabel(cat)"></span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
@click="openProfileModal(profile)"
|
||||
:disabled="profile.is_builtin"
|
||||
:class="profile.is_builtin ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
class="p-1.5 text-gray-400 hover:text-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
:aria-label="`Edit profile ${profile.name}`"
|
||||
:title="profile.is_builtin ? 'Built-in profiles cannot be edited' : 'Edit profile'"
|
||||
>
|
||||
<i class="fas fa-edit text-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
@click="confirmDeleteProfile(profile)"
|
||||
:disabled="profile.is_builtin"
|
||||
:class="profile.is_builtin ? 'opacity-40 cursor-not-allowed' : ''"
|
||||
class="p-1.5 text-gray-400 hover:text-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 rounded"
|
||||
:aria-label="`Delete profile ${profile.name}`"
|
||||
:title="profile.is_builtin ? 'Built-in profiles cannot be deleted' : 'Delete profile'"
|
||||
>
|
||||
<i class="fas fa-trash-alt text-sm" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
@click="openProfileModal(null)"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500"
|
||||
style="min-height:40px;"
|
||||
>
|
||||
<i class="fas fa-plus mr-2" aria-hidden="true"></i> New Profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Profile create / edit modal ────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="profileModalOpen"
|
||||
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-[60] flex items-center justify-center bg-black/50 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
:aria-labelledby="editingProfile ? 'profile-modal-title-edit' : 'profile-modal-title-create'"
|
||||
@keydown.escape.window="closeProfileModal()"
|
||||
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="editingProfile ? 'profile-modal-title-edit' : 'profile-modal-title-create'"
|
||||
class="text-lg font-semibold text-gray-900 dark:text-white"
|
||||
x-text="editingProfile ? 'Edit Profile' : 'New Ingestion Profile'"
|
||||
></h2>
|
||||
<button
|
||||
type="button"
|
||||
@click="closeProfileModal()"
|
||||
class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 rounded"
|
||||
aria-label="Close modal"
|
||||
>
|
||||
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="saveProfile()" class="px-6 pb-6 pt-4 space-y-4">
|
||||
|
||||
<!-- Profile name -->
|
||||
<div>
|
||||
<label for="profile-name" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||||
Profile Name <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</label>
|
||||
<input
|
||||
id="profile-name"
|
||||
type="text"
|
||||
x-model="profileForm.name"
|
||||
placeholder="e.g. Scanner Inbox, Finance Documents"
|
||||
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-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Description -->
|
||||
<div>
|
||||
<label for="profile-description" class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-1">
|
||||
Description
|
||||
</label>
|
||||
<input
|
||||
id="profile-description"
|
||||
type="text"
|
||||
x-model="profileForm.description"
|
||||
placeholder="Optional description"
|
||||
maxlength="500"
|
||||
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-blue-500 dark:bg-gray-700 dark:text-white text-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Category checkboxes -->
|
||||
<div>
|
||||
<fieldset>
|
||||
<legend class="block text-sm font-medium text-gray-700 dark:text-gray-200 mb-2">
|
||||
File Type Categories <span class="text-red-500" aria-hidden="true">*</span>
|
||||
</legend>
|
||||
<div class="space-y-2">
|
||||
<template x-for="cat in categories" :key="cat.key">
|
||||
<label class="flex items-start gap-3 cursor-pointer group">
|
||||
<input
|
||||
type="checkbox"
|
||||
:value="cat.key"
|
||||
:checked="profileForm.allowed_categories.includes(cat.key)"
|
||||
@change="toggleCategory(cat.key)"
|
||||
class="mt-0.5 h-4 w-4 rounded border-gray-300 text-blue-600 focus:ring-blue-500"
|
||||
/>
|
||||
<span class="flex-1">
|
||||
<span class="text-sm font-medium text-gray-800 dark:text-gray-100" x-text="cat.label"></span>
|
||||
<span class="block text-xs text-gray-500 dark:text-gray-400" x-text="cat.description"></span>
|
||||
</span>
|
||||
</label>
|
||||
</template>
|
||||
</div>
|
||||
<p x-show="profileForm.allowed_categories.length === 0" class="mt-2 text-xs text-red-600" role="alert">
|
||||
Select at least one category.
|
||||
</p>
|
||||
</fieldset>
|
||||
</div>
|
||||
|
||||
<!-- Form error -->
|
||||
<template x-if="profileFormError">
|
||||
<p class="text-sm text-red-600" role="alert" x-text="profileFormError"></p>
|
||||
</template>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex justify-end gap-2 pt-2 border-t border-gray-100 dark:border-gray-700">
|
||||
<button
|
||||
type="button"
|
||||
@click="closeProfileModal()"
|
||||
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-blue-500"
|
||||
style="min-height:40px;"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="savingProfile || profileForm.allowed_categories.length === 0"
|
||||
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded bg-blue-600 hover:bg-blue-700 text-white focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||
style="min-height:40px;"
|
||||
>
|
||||
<template x-if="savingProfile">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
<span x-text="editingProfile ? 'Save Changes' : 'Create Profile'"></span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Delete profile confirmation modal ────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="deleteProfileModalOpen"
|
||||
x-transition
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 px-4"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="delete-profile-modal-title"
|
||||
@keydown.escape.window="deleteProfileModalOpen = 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-profile-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white mb-2">
|
||||
Delete Profile?
|
||||
</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-300 mb-4">
|
||||
Are you sure you want to delete the profile
|
||||
<strong x-text="profileToDelete ? profileToDelete.name : ''"></strong>?
|
||||
IMAP accounts using this profile will fall back to the global default.
|
||||
</p>
|
||||
<div class="flex justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
@click="deleteProfileModalOpen = false; profileToDelete = 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="deleteProfile()"
|
||||
:disabled="deletingProfile"
|
||||
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="deletingProfile">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function imapAccountsApp() {
|
||||
return {
|
||||
accounts: {{ accounts | tojson }},
|
||||
profiles: {{ profiles | tojson }},
|
||||
categories: {{ categories | tojson }},
|
||||
defaultCategories: {{ default_categories | tojson }},
|
||||
quota: {
|
||||
current_count: {{ current_count }},
|
||||
max_mailboxes: {{ max_mailboxes | tojson }},
|
||||
@@ -508,17 +823,17 @@ function imapAccountsApp() {
|
||||
loading: false,
|
||||
alert: { show: false, type: '', title: '', message: '' },
|
||||
|
||||
// Modal state
|
||||
// Account modal state
|
||||
modalOpen: false,
|
||||
editingAccount: null,
|
||||
form: { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true },
|
||||
form: { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true, profile_id: null },
|
||||
showPassword: false,
|
||||
saving: false,
|
||||
testing: false,
|
||||
testResult: null,
|
||||
formError: null,
|
||||
|
||||
// Delete state
|
||||
// Account delete state
|
||||
deleteModalOpen: false,
|
||||
accountToDelete: null,
|
||||
deleting: false,
|
||||
@@ -526,6 +841,17 @@ function imapAccountsApp() {
|
||||
// Test (saved account) state
|
||||
testingId: null,
|
||||
|
||||
// Profiles panel & modal state
|
||||
showProfilesSection: false,
|
||||
profileModalOpen: false,
|
||||
editingProfile: null,
|
||||
profileForm: { name: '', description: '', allowed_categories: [] },
|
||||
savingProfile: false,
|
||||
profileFormError: null,
|
||||
deleteProfileModalOpen: false,
|
||||
profileToDelete: null,
|
||||
deletingProfile: false,
|
||||
|
||||
get canAdd() {
|
||||
return this.quota.can_add;
|
||||
},
|
||||
@@ -549,9 +875,33 @@ function imapAccountsApp() {
|
||||
setTimeout(() => { this.alert.show = false; }, 5000);
|
||||
},
|
||||
|
||||
// ── Profile helpers ──────────────────────────────────────────────────────
|
||||
|
||||
profileById(id) {
|
||||
return this.profiles.find(p => p.id === id) || null;
|
||||
},
|
||||
|
||||
profileName(id) {
|
||||
const p = this.profileById(id);
|
||||
return p ? p.name : 'Unknown profile';
|
||||
},
|
||||
|
||||
profileCategorySummary(id) {
|
||||
const p = this.profileById(id);
|
||||
if (!p) return '';
|
||||
return p.allowed_categories.map(k => this.categoryLabel(k)).join(', ');
|
||||
},
|
||||
|
||||
categoryLabel(key) {
|
||||
const cat = this.categories.find(c => c.key === key);
|
||||
return cat ? cat.label : key;
|
||||
},
|
||||
|
||||
// ── Account modal ────────────────────────────────────────────────────────
|
||||
|
||||
openCreateModal() {
|
||||
this.editingAccount = null;
|
||||
this.form = { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true };
|
||||
this.form = { name: '', host: '', port: 993, username: '', password: '', use_ssl: true, delete_after_process: false, is_active: true, profile_id: null };
|
||||
this.showPassword = false;
|
||||
this.testResult = null;
|
||||
this.formError = null;
|
||||
@@ -569,6 +919,7 @@ function imapAccountsApp() {
|
||||
use_ssl: acct.use_ssl,
|
||||
delete_after_process: acct.delete_after_process,
|
||||
is_active: acct.is_active,
|
||||
profile_id: acct.profile_id || null,
|
||||
};
|
||||
this.showPassword = false;
|
||||
this.testResult = null;
|
||||
@@ -592,6 +943,8 @@ function imapAccountsApp() {
|
||||
if (this.editingAccount && payload.password === '') {
|
||||
delete payload.password;
|
||||
}
|
||||
// Normalise profile_id: empty string → null
|
||||
if (payload.profile_id === '' || payload.profile_id === 0) payload.profile_id = null;
|
||||
let resp;
|
||||
if (this.editingAccount) {
|
||||
resp = await fetch(`/api/imap-accounts/${this.editingAccount.id}`, {
|
||||
@@ -719,6 +1072,126 @@ function imapAccountsApp() {
|
||||
this.deleting = false;
|
||||
}
|
||||
},
|
||||
|
||||
// ── Profile CRUD ─────────────────────────────────────────────────────────
|
||||
|
||||
openProfileModal(profile) {
|
||||
this.editingProfile = profile;
|
||||
if (profile) {
|
||||
this.profileForm = {
|
||||
name: profile.name,
|
||||
description: profile.description || '',
|
||||
allowed_categories: [...profile.allowed_categories],
|
||||
};
|
||||
} else {
|
||||
this.profileForm = { name: '', description: '', allowed_categories: [...this.defaultCategories] };
|
||||
}
|
||||
this.profileFormError = null;
|
||||
this.profileModalOpen = true;
|
||||
},
|
||||
|
||||
closeProfileModal() {
|
||||
this.profileModalOpen = false;
|
||||
this.editingProfile = null;
|
||||
this.profileFormError = null;
|
||||
},
|
||||
|
||||
toggleCategory(key) {
|
||||
const idx = this.profileForm.allowed_categories.indexOf(key);
|
||||
if (idx === -1) {
|
||||
this.profileForm.allowed_categories.push(key);
|
||||
} else {
|
||||
this.profileForm.allowed_categories.splice(idx, 1);
|
||||
}
|
||||
},
|
||||
|
||||
async saveProfile() {
|
||||
if (this.profileForm.allowed_categories.length === 0) {
|
||||
this.profileFormError = 'Select at least one category.';
|
||||
return;
|
||||
}
|
||||
this.profileFormError = null;
|
||||
this.savingProfile = true;
|
||||
try {
|
||||
const payload = {
|
||||
name: this.profileForm.name,
|
||||
description: this.profileForm.description || null,
|
||||
allowed_categories: this.profileForm.allowed_categories,
|
||||
};
|
||||
let resp;
|
||||
if (this.editingProfile) {
|
||||
resp = await fetch(`/api/imap-profiles/${this.editingProfile.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
} else {
|
||||
resp = await fetch('/api/imap-profiles/', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': getCsrfToken() },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (!resp.ok) {
|
||||
this.profileFormError = data.detail || 'Failed to save profile.';
|
||||
return;
|
||||
}
|
||||
// Flatten response into the same shape used locally
|
||||
const normalized = {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
description: data.description,
|
||||
owner_id: data.owner_id,
|
||||
allowed_categories: data.allowed_categories,
|
||||
is_builtin: data.is_builtin,
|
||||
};
|
||||
if (this.editingProfile) {
|
||||
const idx = this.profiles.findIndex(p => p.id === normalized.id);
|
||||
if (idx !== -1) this.profiles.splice(idx, 1, normalized);
|
||||
} else {
|
||||
this.profiles.push(normalized);
|
||||
}
|
||||
this.closeProfileModal();
|
||||
this.showAlert('success', 'Saved', this.editingProfile ? 'Profile updated.' : 'Profile created.');
|
||||
} catch (err) {
|
||||
this.profileFormError = `Network error: ${err.message || 'Unknown error'}. Please try again.`;
|
||||
} finally {
|
||||
this.savingProfile = false;
|
||||
}
|
||||
},
|
||||
|
||||
confirmDeleteProfile(profile) {
|
||||
this.profileToDelete = profile;
|
||||
this.deleteProfileModalOpen = true;
|
||||
},
|
||||
|
||||
async deleteProfile() {
|
||||
if (!this.profileToDelete) return;
|
||||
this.deletingProfile = true;
|
||||
try {
|
||||
const resp = await fetch(`/api/imap-profiles/${this.profileToDelete.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': getCsrfToken() },
|
||||
});
|
||||
if (resp.ok || resp.status === 204) {
|
||||
const deletedId = this.profileToDelete.id;
|
||||
this.profiles = this.profiles.filter(p => p.id !== deletedId);
|
||||
// Clear profile_id on accounts that referenced this profile
|
||||
this.accounts.forEach(a => { if (a.profile_id === deletedId) a.profile_id = null; });
|
||||
this.showAlert('success', 'Deleted', `Profile "${this.profileToDelete.name}" removed.`);
|
||||
this.deleteProfileModalOpen = false;
|
||||
this.profileToDelete = null;
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.showAlert('error', 'Delete Failed', data.detail || 'Could not delete profile.');
|
||||
}
|
||||
} catch (err) {
|
||||
this.showAlert('error', 'Delete Failed', `Network error: ${err.message || 'Unknown error'}`);
|
||||
} finally {
|
||||
this.deletingProfile = false;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user