c7d3ec57c3
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
440 lines
19 KiB
HTML
440 lines
19 KiB
HTML
{% extends "base.html" %}
|
|
|
|
{% block title %}{{ _("connections.title") }} - {{ _("app.name") }}{% endblock %}
|
|
|
|
{% block content %}
|
|
<main id="main-content" class="max-w-4xl mx-auto px-4 py-8">
|
|
<div class="mb-8">
|
|
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">{{ _("connections.title") }}</h1>
|
|
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">{{ _("connections.description") }}</p>
|
|
</div>
|
|
|
|
<!-- SSO Auto Login Section -->
|
|
{% if oauth_configured %}
|
|
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.sso_auto_login_title") }}</h2>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.sso_auto_login_description") }}</p>
|
|
</div>
|
|
<div>
|
|
<label class="doc-toggle">
|
|
<input type="checkbox" id="sso-auto-login-toggle" class="sr-only"
|
|
{% if sso_auto_login %}checked{% endif %}
|
|
onchange="toggleSetting('sso_auto_login', this.checked)">
|
|
<span class="doc-toggle-track" aria-hidden="true"></span>
|
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.sso_auto_login") }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
{% endif %}
|
|
|
|
<!-- Mobile Phone Upload Section -->
|
|
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6">
|
|
<div class="flex items-center justify-between">
|
|
<div>
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">{{ _("connections.mobile_upload_title") }}</h2>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.mobile_upload_description") }}</p>
|
|
{% if not frontend_url_configured %}
|
|
<p class="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
|
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
|
{{ _("connections.frontend_url_note") }}
|
|
</p>
|
|
{% endif %}
|
|
</div>
|
|
<div>
|
|
<label class="doc-toggle">
|
|
<input type="checkbox" id="qr-upload-toggle" class="sr-only"
|
|
{% if qr_login_enabled %}checked{% endif %}
|
|
onchange="toggleSetting('qr_login_enabled', this.checked)">
|
|
<span class="doc-toggle-track" aria-hidden="true"></span>
|
|
<span class="ml-3 text-sm font-medium text-gray-700 dark:text-gray-300">{{ _("connections.qr_code_enabled") }}</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
<!-- Services Section -->
|
|
<section class="mb-6">
|
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">{{ _("connections.unlinked_services") }}</h2>
|
|
<div class="space-y-3">
|
|
{% for service in services %}
|
|
<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-center gap-3 flex-1 min-w-0">
|
|
<div class="flex-shrink-0 w-10 h-10 flex items-center justify-center rounded-lg bg-gray-100 dark:bg-gray-700">
|
|
<i class="{{ service.icon }} text-lg" 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">{{ service.name }}</h3>
|
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ service.description }}</p>
|
|
</div>
|
|
</div>
|
|
<div class="flex items-center gap-3">
|
|
{% if service.linked %}
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
|
|
<i class="fas fa-check-circle mr-1" aria-hidden="true"></i> {{ _("connections.linked") }}
|
|
</span>
|
|
{% else %}
|
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
|
{{ _("connections.unlinked") }}
|
|
</span>
|
|
{% endif %}
|
|
<button
|
|
type="button"
|
|
class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
|
style="min-height:44px; min-width:44px"
|
|
onclick="openServiceModal('{{ service.key }}')"
|
|
aria-label="{{ _('connections.configure') }} {{ service.name }}"
|
|
>
|
|
<i class="fas fa-cog mr-2" aria-hidden="true"></i>
|
|
{{ _("connections.configure") }}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</section>
|
|
|
|
<p class="text-xs text-gray-400 dark:text-gray-500 mt-4">
|
|
<i class="fas fa-info-circle mr-1" aria-hidden="true"></i>
|
|
{{ _("connections.save_note") }}
|
|
</p>
|
|
</main>
|
|
|
|
<!-- Service Configuration Modal -->
|
|
<div id="service-modal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-4 hidden" role="dialog" aria-modal="true" aria-labelledby="service-modal-title" aria-describedby="service-modal-description">
|
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl w-full max-w-lg overflow-y-auto max-h-[90vh]" onclick="event.stopPropagation()">
|
|
<div class="px-6 pt-6 pb-2 flex items-center justify-between border-b border-gray-200 dark:border-gray-700">
|
|
<div>
|
|
<h2 id="service-modal-title" class="text-lg font-semibold text-gray-900 dark:text-white"></h2>
|
|
<p id="service-modal-description" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("connections.save_note") }}</p>
|
|
</div>
|
|
<button type="button" onclick="closeServiceModal()" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 focus:outline-none focus:ring-2 focus:ring-indigo-500 rounded-md p-1" style="min-height:44px; min-width:44px" aria-label="Close">
|
|
<i class="fas fa-times text-xl" aria-hidden="true"></i>
|
|
</button>
|
|
</div>
|
|
<form id="service-form" onsubmit="saveServiceSettings(event)">
|
|
<div id="service-fields" class="px-6 py-4 space-y-4">
|
|
<!-- Dynamically populated -->
|
|
</div>
|
|
<div class="px-6 py-4 border-t border-gray-200 dark:border-gray-700 flex justify-end gap-3">
|
|
<button type="button" onclick="closeServiceModal()" class="px-4 py-2 text-sm font-medium rounded-md border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700" style="min-height:44px;">
|
|
{{ _("common.cancel") }}
|
|
</button>
|
|
<button type="submit" class="inline-flex items-center px-4 py-2 text-sm font-medium rounded-md bg-indigo-600 hover:bg-indigo-700 text-white focus:outline-none focus:ring-2 focus:ring-indigo-500" style="min-height:44px;">
|
|
<i class="fas fa-save mr-2" aria-hidden="true"></i>
|
|
{{ _("common.save") }}
|
|
</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Accessible notification banner -->
|
|
<div id="save-notification" class="fixed top-4 right-4 z-[60] hidden max-w-sm" role="alert" aria-live="assertive">
|
|
<div id="save-notification-inner" class="rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2">
|
|
<i id="save-notification-icon" class="fas" aria-hidden="true"></i>
|
|
<span id="save-notification-text"></span>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
const serviceSettings = {{ service_settings | tojson }};
|
|
const settingMetadata = {};
|
|
{% for svc_key, svc_fields in service_settings.items() %}
|
|
{% for field in svc_fields %}
|
|
settingMetadata['{{ field.key }}'] = {{ field.metadata | tojson }};
|
|
{% endfor %}
|
|
{% endfor %}
|
|
|
|
let currentServiceKey = null;
|
|
|
|
function openServiceModal(serviceKey) {
|
|
currentServiceKey = serviceKey;
|
|
const fields = serviceSettings[serviceKey] || [];
|
|
const modal = document.getElementById('service-modal');
|
|
const title = document.getElementById('service-modal-title');
|
|
const container = document.getElementById('service-fields');
|
|
|
|
// Find service name from the page
|
|
const serviceCards = document.querySelectorAll('[onclick*="' + serviceKey + '"]');
|
|
let serviceName = serviceKey;
|
|
if (serviceCards.length > 0) {
|
|
const card = serviceCards[0].closest('.bg-white, .dark\\:bg-gray-800');
|
|
if (card) {
|
|
const h3 = card.querySelector('h3');
|
|
if (h3) serviceName = h3.textContent.trim();
|
|
}
|
|
}
|
|
title.textContent = '{{ _("connections.configure") }} ' + serviceName;
|
|
|
|
container.innerHTML = '';
|
|
fields.forEach(function(field) {
|
|
const meta = field.metadata || {};
|
|
const div = document.createElement('div');
|
|
div.setAttribute('data-field-key', field.key);
|
|
|
|
if (meta.type === 'boolean') {
|
|
// For boolean fields use a plain paragraph for the title so the toggle's
|
|
// own <label> wrapper is the only interactive label (no nested <label>s).
|
|
const titleEl = document.createElement('p');
|
|
titleEl.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
|
titleEl.textContent = field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
|
div.appendChild(titleEl);
|
|
} else {
|
|
const label = document.createElement('label');
|
|
label.setAttribute('for', 'field-' + field.key);
|
|
label.className = 'block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1';
|
|
label.textContent = field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); });
|
|
div.appendChild(label);
|
|
}
|
|
|
|
if (meta.description) {
|
|
const desc = document.createElement('p');
|
|
desc.className = 'text-xs text-gray-500 dark:text-gray-400 mb-2';
|
|
desc.textContent = meta.description;
|
|
div.appendChild(desc);
|
|
}
|
|
|
|
if (meta.type === 'boolean') {
|
|
// Styled toggle switch using .doc-toggle CSS class (compatible with Tailwind v2 CDN).
|
|
const toggleWrapper = document.createElement('label');
|
|
toggleWrapper.className = 'doc-toggle';
|
|
toggleWrapper.setAttribute('aria-label', field.key.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }));
|
|
|
|
const checkbox = document.createElement('input');
|
|
checkbox.type = 'checkbox';
|
|
checkbox.id = 'field-' + field.key;
|
|
checkbox.name = field.key;
|
|
checkbox.className = 'sr-only';
|
|
const val = field.value;
|
|
if (val === true || val === 'true' || val === '1' || val === 'True') {
|
|
checkbox.checked = true;
|
|
}
|
|
|
|
const slider = document.createElement('span');
|
|
slider.className = 'doc-toggle-track';
|
|
slider.setAttribute('aria-hidden', 'true');
|
|
|
|
toggleWrapper.appendChild(checkbox);
|
|
toggleWrapper.appendChild(slider);
|
|
div.appendChild(toggleWrapper);
|
|
|
|
// When the Dropbox "use global credentials" toggle changes, update the
|
|
// visibility of the separate client-id / client-secret fields.
|
|
if (field.key === 'social_auth_dropbox_use_global_credentials') {
|
|
checkbox.addEventListener('change', function() {
|
|
updateDropboxCredentialFieldsVisibility(this.checked);
|
|
});
|
|
}
|
|
// When the Google "use global credentials" toggle changes, update visibility.
|
|
if (field.key === 'social_auth_google_use_global_credentials') {
|
|
checkbox.addEventListener('change', function() {
|
|
updateGoogleCredentialFieldsVisibility(this.checked);
|
|
});
|
|
}
|
|
// When the Microsoft "use global credentials" toggle changes, update visibility.
|
|
if (field.key === 'social_auth_microsoft_use_global_credentials') {
|
|
checkbox.addEventListener('change', function() {
|
|
updateMicrosoftCredentialFieldsVisibility(this.checked);
|
|
});
|
|
}
|
|
} else {
|
|
const input = document.createElement('input');
|
|
input.type = meta.sensitive ? 'password' : 'text';
|
|
input.id = 'field-' + field.key;
|
|
input.name = field.key;
|
|
input.className = 'w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 dark:bg-gray-700 dark:text-white text-sm';
|
|
input.placeholder = meta.sensitive ? '••••••••' : '';
|
|
// Don't pre-fill sensitive values with masked data
|
|
if (!meta.sensitive && field.value != null) {
|
|
input.value = field.value;
|
|
}
|
|
div.appendChild(input);
|
|
}
|
|
|
|
if (meta.help_link) {
|
|
const link = document.createElement('a');
|
|
link.href = meta.help_link;
|
|
link.target = '_blank';
|
|
link.rel = 'noopener noreferrer';
|
|
link.className = 'text-xs text-indigo-600 hover:text-indigo-500 mt-1 inline-block';
|
|
link.textContent = meta.help_link_label || 'Documentation';
|
|
div.appendChild(link);
|
|
}
|
|
|
|
container.appendChild(div);
|
|
});
|
|
|
|
// Apply initial visibility for Dropbox credential fields based on the
|
|
// current value of the "use global credentials" toggle.
|
|
if (currentServiceKey === 'dropbox') {
|
|
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_dropbox_use_global_credentials'; });
|
|
if (useGlobalField) {
|
|
const val = useGlobalField.value;
|
|
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
|
|
updateDropboxCredentialFieldsVisibility(isGlobal);
|
|
}
|
|
}
|
|
// Apply initial visibility for Google credential fields.
|
|
if (currentServiceKey === 'google') {
|
|
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_google_use_global_credentials'; });
|
|
if (useGlobalField) {
|
|
const val = useGlobalField.value;
|
|
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
|
|
updateGoogleCredentialFieldsVisibility(isGlobal);
|
|
}
|
|
}
|
|
// Apply initial visibility for Microsoft credential fields.
|
|
if (currentServiceKey === 'microsoft') {
|
|
const useGlobalField = fields.find(function(f) { return f.key === 'social_auth_microsoft_use_global_credentials'; });
|
|
if (useGlobalField) {
|
|
const val = useGlobalField.value;
|
|
const isGlobal = val === true || val === 'true' || val === '1' || val === 'True';
|
|
updateMicrosoftCredentialFieldsVisibility(isGlobal);
|
|
}
|
|
}
|
|
|
|
modal.classList.remove('hidden');
|
|
// Focus first input
|
|
setTimeout(function() {
|
|
const firstInput = container.querySelector('input');
|
|
if (firstInput) firstInput.focus();
|
|
}, 100);
|
|
}
|
|
|
|
function closeServiceModal() {
|
|
document.getElementById('service-modal').classList.add('hidden');
|
|
currentServiceKey = null;
|
|
}
|
|
|
|
function saveServiceSettings(event) {
|
|
event.preventDefault();
|
|
if (!currentServiceKey) return;
|
|
|
|
const fields = serviceSettings[currentServiceKey] || [];
|
|
const updates = {};
|
|
|
|
fields.forEach(function(field) {
|
|
const meta = field.metadata || {};
|
|
const el = document.getElementById('field-' + field.key);
|
|
if (!el) return;
|
|
|
|
if (meta.type === 'boolean') {
|
|
updates[field.key] = el.checked ? 'true' : 'false';
|
|
} else {
|
|
// Only send non-empty values for sensitive fields (empty = no change)
|
|
if (meta.sensitive && !el.value) return;
|
|
updates[field.key] = el.value;
|
|
}
|
|
});
|
|
|
|
// Save each setting via the API
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
const promises = Object.entries(updates).map(function([key, value]) {
|
|
return fetch('/api/settings/' + key, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': csrfToken,
|
|
},
|
|
body: JSON.stringify({ value: value }),
|
|
});
|
|
});
|
|
|
|
Promise.all(promises)
|
|
.then(function(responses) {
|
|
const allOk = responses.every(function(r) { return r.ok; });
|
|
if (allOk) {
|
|
closeServiceModal();
|
|
location.reload();
|
|
} else {
|
|
showNotification('error', 'Some settings failed to save. Please check the values and try again.');
|
|
}
|
|
})
|
|
.catch(function(err) {
|
|
console.error('Error saving settings:', err);
|
|
showNotification('error', 'Failed to save settings. Please try again.');
|
|
});
|
|
}
|
|
|
|
// Show or hide the Dropbox client-id / client-secret fields depending on
|
|
// whether the "use global credentials" toggle is enabled.
|
|
function updateDropboxCredentialFieldsVisibility(useGlobal) {
|
|
['social_auth_dropbox_client_id', 'social_auth_dropbox_client_secret'].forEach(function(key) {
|
|
const el = document.querySelector('[data-field-key="' + key + '"]');
|
|
if (el) {
|
|
el.style.display = useGlobal ? 'none' : '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Show or hide the Google client-id / client-secret fields depending on
|
|
// whether the "use global credentials" toggle is enabled.
|
|
function updateGoogleCredentialFieldsVisibility(useGlobal) {
|
|
['social_auth_google_client_id', 'social_auth_google_client_secret'].forEach(function(key) {
|
|
const el = document.querySelector('[data-field-key="' + key + '"]');
|
|
if (el) {
|
|
el.style.display = useGlobal ? 'none' : '';
|
|
}
|
|
});
|
|
}
|
|
|
|
// Show or hide the Microsoft client-id / client-secret fields depending on
|
|
// whether the "use global credentials" toggle is enabled.
|
|
function updateMicrosoftCredentialFieldsVisibility(useGlobal) {
|
|
['social_auth_microsoft_client_id', 'social_auth_microsoft_client_secret'].forEach(function(key) {
|
|
const el = document.querySelector('[data-field-key="' + key + '"]');
|
|
if (el) {
|
|
el.style.display = useGlobal ? 'none' : '';
|
|
}
|
|
});
|
|
}
|
|
|
|
function toggleSetting(key, value) {
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
|
fetch('/api/settings/' + key, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-Token': csrfToken,
|
|
},
|
|
body: JSON.stringify({ value: value ? 'true' : 'false' }),
|
|
}).catch(function(err) {
|
|
console.error('Error toggling setting:', err);
|
|
});
|
|
}
|
|
|
|
// Close modal on Escape
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.key === 'Escape') closeServiceModal();
|
|
});
|
|
|
|
// Close modal on backdrop click
|
|
document.getElementById('service-modal')?.addEventListener('click', function(e) {
|
|
if (e.target === this) closeServiceModal();
|
|
});
|
|
|
|
function showNotification(type, message) {
|
|
var el = document.getElementById('save-notification');
|
|
var inner = document.getElementById('save-notification-inner');
|
|
var icon = document.getElementById('save-notification-icon');
|
|
var text = document.getElementById('save-notification-text');
|
|
if (!el) return;
|
|
text.textContent = message;
|
|
if (type === 'error') {
|
|
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
|
icon.className = 'fas fa-exclamation-circle';
|
|
} else {
|
|
inner.className = 'rounded-lg shadow-lg px-4 py-3 text-sm font-medium flex items-center gap-2 bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200';
|
|
icon.className = 'fas fa-check-circle';
|
|
}
|
|
el.classList.remove('hidden');
|
|
setTimeout(function() { el.classList.add('hidden'); }, 5000);
|
|
}
|
|
</script>
|
|
{% endblock %}
|