542fb46ee7
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
303 lines
13 KiB
HTML
303 lines
13 KiB
HTML
{% 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 %}
|