fix(migrations): merge main and rechain compliance migration as 031 after 030_add_mobile_devices

Merge main branch into compliance templates feature branch.

Main had advanced with migrations 027-030 (ensure_shared_links,
audit_logs, user_language_preference, mobile_devices) since this
branch forked. Our compliance migration was 027 with down_revision
026, which conflicted with main's 027_ensure_shared_links_table.

Changes:
- Merge main (including i18n, audit logs, mobile, GraphQL features)
- Resolve conflicts in app/api/__init__.py, app/models.py, tests/conftest.py
- Rename 027_add_compliance_templates → 031_add_compliance_templates
- Rechain: down_revision 026_add_scheduled_jobs → 030_add_mobile_devices
- Add ComplianceTemplate to migrations/env.py imports
- Alembic now has single head: 031_add_compliance_templates
This commit is contained in:
copilot-swe-agent[bot]
2026-03-12 00:00:29 +00:00
94 changed files with 23120 additions and 98 deletions
+222
View File
@@ -0,0 +1,222 @@
{% extends "base.html" %}
{% block title %}Audit Logs - DocuElevate{% endblock %}
{% block content %}
<div x-data="auditLogs()" x-init="fetchLogs()" class="container mx-auto px-4 py-8 max-w-7xl">
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-center md:justify-between mb-6">
<div>
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-shield-halved mr-2 text-indigo-600" aria-hidden="true"></i>Audit Logs
</h1>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
Comprehensive, append-only record of all significant actions.
</p>
</div>
<div class="mt-3 md:mt-0 flex items-center gap-3">
{% if siem_enabled %}
<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-800 dark:text-green-100"
title="Events are being forwarded to {{ siem_transport|upper }}">
<i class="fas fa-tower-broadcast mr-1" aria-hidden="true"></i>SIEM: {{ siem_transport|upper }}
</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-300"
title="SIEM forwarding is disabled">
<i class="fas fa-tower-broadcast mr-1" aria-hidden="true"></i>SIEM: Off
</span>
{% endif %}
<button @click="fetchLogs()" class="inline-flex items-center px-3 py-1.5 border border-gray-300 rounded-md text-sm font-medium text-gray-700 bg-white hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-200 dark:border-gray-600 dark:hover:bg-gray-600 min-h-[44px] min-w-[44px]"
aria-label="Refresh audit logs">
<i class="fas fa-arrows-rotate mr-1" aria-hidden="true"></i>Refresh
</button>
</div>
</div>
<!-- Filters -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 mb-6" aria-label="Audit log filters">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="filter-action" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Action</label>
<select id="filter-action" x-model="filters.action" @change="fetchLogs()"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm">
<option value="">All actions</option>
<template x-for="a in distinctActions" :key="a">
<option :value="a" x-text="a"></option>
</template>
</select>
</div>
<div>
<label for="filter-user" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">User</label>
<select id="filter-user" x-model="filters.user" @change="fetchLogs()"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm">
<option value="">All users</option>
<template x-for="u in distinctUsers" :key="u">
<option :value="u" x-text="u"></option>
</template>
</select>
</div>
<div>
<label for="filter-severity" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Severity</label>
<select id="filter-severity" x-model="filters.severity" @change="fetchLogs()"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm">
<option value="">All</option>
<option value="info">Info</option>
<option value="warning">Warning</option>
<option value="error">Error</option>
<option value="critical">Critical</option>
</select>
</div>
<div>
<label for="filter-resource" class="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">Resource Type</label>
<input id="filter-resource" type="text" x-model.debounce.300ms="filters.resource_type" @input="fetchLogs()"
placeholder="e.g. document, user"
class="w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white text-sm min-h-[44px]">
</div>
</div>
</section>
<!-- Results summary -->
<div class="flex items-center justify-between mb-3 text-sm text-gray-500 dark:text-gray-400" aria-live="polite">
<span x-show="!loading" x-text="total + ' event' + (total !== 1 ? 's' : '') + ' found'"></span>
<span x-show="loading"><i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>Loading…</span>
</div>
<!-- Table -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700" aria-label="Audit log events">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Timestamp</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Severity</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">User</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Action</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Resource</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">IP</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Details</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="e in entries" :key="e.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-600 dark:text-gray-300" x-text="formatTs(e.timestamp)"></td>
<td class="px-4 py-2 whitespace-nowrap text-xs">
<span :class="severityClass(e.severity)" class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium" x-text="e.severity"></span>
</td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-700 dark:text-gray-200" x-text="e.user"></td>
<td class="px-4 py-2 whitespace-nowrap text-xs font-mono text-indigo-700 dark:text-indigo-300" x-text="e.action"></td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-600 dark:text-gray-300">
<span x-text="e.resource_type || '—'"></span>
<span x-show="e.resource_id" class="text-gray-400" x-text="' #' + e.resource_id"></span>
</td>
<td class="px-4 py-2 whitespace-nowrap text-xs text-gray-500 dark:text-gray-400" x-text="e.ip_address || '—'"></td>
<td class="px-4 py-2 text-xs text-gray-500 dark:text-gray-400 max-w-xs truncate" :title="e.details ? JSON.stringify(e.details) : ''" x-text="e.details ? JSON.stringify(e.details) : '—'"></td>
</tr>
</template>
<tr x-show="!loading && entries.length === 0">
<td colspan="7" class="px-4 py-8 text-center text-gray-400 dark:text-gray-500">
<i class="fas fa-inbox text-3xl mb-2" aria-hidden="true"></i>
<p class="text-lg">No audit events recorded yet.</p>
<p class="text-sm mt-1">Significant actions (logins, document operations, settings changes) will appear here.</p>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Pagination -->
<nav class="flex items-center justify-between mt-4" aria-label="Audit log pagination" x-show="total > filters.limit">
<button @click="prevPage()" :disabled="filters.offset === 0"
class="px-3 py-1.5 rounded-md text-sm border border-gray-300 dark:border-gray-600 disabled:opacity-50 min-h-[44px] min-w-[44px]"
aria-label="Previous page">
<i class="fas fa-chevron-left mr-1" aria-hidden="true"></i>Prev
</button>
<span class="text-sm text-gray-500 dark:text-gray-400" x-text="'Page ' + currentPage + ' of ' + totalPages"></span>
<button @click="nextPage()" :disabled="filters.offset + filters.limit >= total"
class="px-3 py-1.5 rounded-md text-sm border border-gray-300 dark:border-gray-600 disabled:opacity-50 min-h-[44px] min-w-[44px]"
aria-label="Next page">
Next<i class="fas fa-chevron-right ml-1" aria-hidden="true"></i>
</button>
</nav>
</div>
<script>
function auditLogs() {
return {
entries: [],
total: 0,
loading: false,
distinctActions: [],
distinctUsers: [],
filters: {
action: '',
user: '',
severity: '',
resource_type: '',
limit: 50,
offset: 0,
},
get currentPage() { return Math.floor(this.filters.offset / this.filters.limit) + 1; },
get totalPages() { return Math.max(1, Math.ceil(this.total / this.filters.limit)); },
async fetchLogs() {
this.loading = true;
try {
const params = new URLSearchParams();
if (this.filters.action) params.set('action', this.filters.action);
if (this.filters.user) params.set('user', this.filters.user);
if (this.filters.severity) params.set('severity', this.filters.severity);
if (this.filters.resource_type) params.set('resource_type', this.filters.resource_type);
params.set('limit', this.filters.limit);
params.set('offset', this.filters.offset);
const [logsResp, actionsResp, usersResp] = await Promise.all([
fetch('/api/audit-logs?' + params.toString()),
fetch('/api/audit-logs/actions'),
fetch('/api/audit-logs/users'),
]);
if (logsResp.ok) {
const data = await logsResp.json();
this.entries = data.items;
this.total = data.total;
}
if (actionsResp.ok) this.distinctActions = await actionsResp.json();
if (usersResp.ok) this.distinctUsers = await usersResp.json();
} finally {
this.loading = false;
}
},
prevPage() {
if (this.filters.offset > 0) {
this.filters.offset = Math.max(0, this.filters.offset - this.filters.limit);
this.fetchLogs();
}
},
nextPage() {
if (this.filters.offset + this.filters.limit < this.total) {
this.filters.offset += this.filters.limit;
this.fetchLogs();
}
},
formatTs(iso) {
if (!iso) return '—';
const d = new Date(iso);
return d.toLocaleString();
},
severityClass(sev) {
const map = {
info: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
warning: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
error: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
critical: 'bg-red-200 text-red-900 dark:bg-red-800 dark:text-red-100',
};
return map[sev] || 'bg-gray-100 text-gray-800';
},
};
}
</script>
{% endblock %}
+146 -76
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-color-scheme-default="{{ ui_default_color_scheme | default('system') }}">
<html lang="{{ current_locale | default('en') }}" data-color-scheme-default="{{ ui_default_color_scheme | default('system') }}">
<head>
<meta charset="UTF-8" />
<title>{% block title %}DocuElevate{% endblock %}</title>
@@ -37,10 +37,10 @@
data-multi-user="{{ 'true' if multi_user_enabled else 'false' }}"
data-allow-signup="{{ 'true' if allow_signup else 'false' }}">
<!-- Skip to main content link for keyboard/screen reader users -->
<a href="#main-content" class="skip-link">Skip to main content</a>
<a href="#main-content" class="skip-link">{{ _("nav.skip_to_content") }}</a>
<!-- Global Nav -->
<nav class="bg-white shadow relative" aria-label="Main navigation">
<nav class="bg-white shadow relative" aria-label="{{ _('nav.main_navigation') }}">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 flex justify-between h-16 items-center">
<!-- Brand + Icon -->
@@ -70,12 +70,12 @@
<a href="/pricing"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/pricing' %}aria-current="page"{% endif %}>
Pricing
{{ _("nav.pricing") }}
</a>
<a href="/about"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/about' %}aria-current="page"{% endif %}>
About
{{ _("nav.about") }}
</a>
{% else %}
@@ -84,32 +84,32 @@
<a href="/"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/' %}aria-current="page"{% endif %}>
<i class="fas fa-home mr-1 text-gray-400" aria-hidden="true"></i>Dashboard
<i class="fas fa-home mr-1 text-gray-400" aria-hidden="true"></i>{{ _("nav.dashboard") }}
</a>
<a href="/upload"
class="px-3 py-2 rounded-md text-sm font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50"
{% if request and request.url.path == '/upload' %}aria-current="page"{% endif %}>
<i class="fas fa-upload mr-1" aria-hidden="true"></i>Upload
<i class="fas fa-upload mr-1" aria-hidden="true"></i>{{ _("nav.upload") }}
</a>
<a href="/files"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/files' %}aria-current="page"{% endif %}>
<i class="fas fa-folder-open mr-1 text-gray-400" aria-hidden="true"></i>Files
<i class="fas fa-folder-open mr-1 text-gray-400" aria-hidden="true"></i>{{ _("nav.files") }}
</a>
<a href="/search"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/search' %}aria-current="page"{% endif %}>
<i class="fas fa-search mr-1 text-gray-400" aria-hidden="true"></i>Search
<i class="fas fa-search mr-1 text-gray-400" aria-hidden="true"></i>{{ _("nav.search") }}
</a>
<a href="/pipelines"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
<i class="fas fa-project-diagram mr-1 text-gray-400" aria-hidden="true"></i>Pipelines
<i class="fas fa-project-diagram mr-1 text-gray-400" aria-hidden="true"></i>{{ _("nav.pipelines") }}
</a>
<a href="/integrations"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
<i class="fas fa-plug mr-1 text-gray-400" aria-hidden="true"></i>Integrations
<i class="fas fa-plug mr-1 text-gray-400" aria-hidden="true"></i>{{ _("nav.integrations") }}
</a>
<!-- Admin dropdown shown only for admin users via JS -->
@@ -124,7 +124,7 @@
:aria-expanded="adminMenuOpen"
>
<i class="fas fa-shield-alt mr-1 text-red-500" aria-hidden="true"></i>
Admin
{{ _("nav.admin") }}
<i class="fas fa-chevron-down ml-1 text-xs" aria-hidden="true"></i>
</button>
<div
@@ -141,49 +141,55 @@
>
<div class="py-1">
<a href="/settings" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-cog w-4 mr-2 text-gray-500" aria-hidden="true"></i> Settings
<i class="fas fa-cog w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.settings") }}
</a>
<a href="/admin/users" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-users w-4 mr-2 text-blue-500" aria-hidden="true"></i> Users
<i class="fas fa-users w-4 mr-2 text-blue-500" aria-hidden="true"></i> {{ _("nav.users") }}
</a>
<a href="/admin/plans" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-layer-group w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Plan Designer
<i class="fas fa-layer-group w-4 mr-2 text-indigo-500" aria-hidden="true"></i> {{ _("nav.plan_designer") }}
</a>
<a href="/admin/credentials" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> Credentials
<i class="fas fa-key w-4 mr-2 text-yellow-500" aria-hidden="true"></i> {{ _("nav.credentials") }}
</a>
<a href="/admin/files" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> File Manager
<i class="fas fa-folder-open w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.file_manager") }}
</a>
<div class="border-t border-gray-100 my-1"></div>
<a href="/duplicates" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-clone w-4 mr-2 text-orange-500" aria-hidden="true"></i> Duplicates
<i class="fas fa-clone w-4 mr-2 text-orange-500" aria-hidden="true"></i> {{ _("nav.duplicates") }}
</a>
<a href="/similarity" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-sitemap w-4 mr-2 text-purple-500" aria-hidden="true"></i> Similarity
<i class="fas fa-sitemap w-4 mr-2 text-purple-500" aria-hidden="true"></i> {{ _("nav.similarity") }}
</a>
<a href="/admin/queue" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-stream w-4 mr-2 text-blue-500" aria-hidden="true"></i> Queue Monitor
<i class="fas fa-stream w-4 mr-2 text-blue-500" aria-hidden="true"></i> {{ _("nav.queue_monitor") }}
</a>
<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> Scheduled Jobs
<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> Backup &amp; Restore
<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> Status
<i class="fas fa-circle-dot w-4 mr-2 text-gray-500" aria-hidden="true"></i> {{ _("nav.status") }}
</a>
<div class="border-t border-gray-100 my-1"></div>
<a href="/admin/api-docs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-code w-4 mr-2 text-teal-500" aria-hidden="true"></i> API Docs
<i class="fas fa-code w-4 mr-2 text-teal-500" aria-hidden="true"></i> {{ _("nav.api_docs") }}
</a>
<a href="/developer-docs/" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-book w-4 mr-2 text-teal-500" aria-hidden="true"></i> Developer Docs
<i class="fas fa-book w-4 mr-2 text-teal-500" aria-hidden="true"></i> {{ _("nav.developer_docs") }}
</a>
</div>
</div>
@@ -194,18 +200,18 @@
<!-- Help always visible, for every visitor regardless of auth state -->
<a href="/help"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
aria-label="Help Center"
title="Help Center"
aria-label="{{ _('nav.help_center') }}"
title="{{ _('nav.help_center') }}"
{% if request and request.url.path == '/help' %}aria-current="page"{% endif %}>
<i class="fas fa-circle-question mr-1 text-gray-400" aria-hidden="true"></i>Help
<i class="fas fa-circle-question mr-1 text-gray-400" aria-hidden="true"></i>{{ _("nav.help") }}
</a>
<!-- Bell notification icon -->
<a href="/notifications"
id="notificationBell"
class="relative p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-label="Notifications"
title="Notifications"
aria-label="{{ _('nav.notifications') }}"
title="{{ _('nav.notifications') }}"
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
<i class="fas fa-bell" aria-hidden="true"></i>
<span id="notificationBadge"
@@ -213,14 +219,59 @@
aria-live="polite"></span>
</a>
<!-- Language selector dropdown -->
<div x-data="{ langOpen: false }" class="relative">
<button
@click="langOpen = !langOpen"
@click.outside="langOpen = false"
type="button"
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-label="{{ _('language.selector') }}"
title="{{ _('language.selector') }}"
:aria-expanded="langOpen"
aria-haspopup="true"
>
<i class="fas fa-globe"></i>
</button>
<div
x-show="langOpen"
x-transition:enter="transition ease-out duration-100 transform"
x-transition:enter-start="opacity-0 scale-95"
x-transition:enter-end="opacity-100 scale-100"
x-transition:leave="transition ease-in duration-75 transform"
x-transition:leave-start="opacity-100 scale-100"
x-transition:leave-end="opacity-0 scale-95"
class="absolute right-0 mt-2 w-48 bg-white rounded-md shadow-lg ring-1 ring-black ring-opacity-5 z-50"
role="menu"
aria-label="{{ _('language.selector') }}"
>
<div class="py-1">
{% for lang in supported_languages %}
<button
type="button"
role="menuitem"
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 {% if current_locale == lang.code %}bg-blue-50 text-blue-700 font-medium{% else %}text-gray-700{% endif %}"
onclick="setLanguage('{{ lang.code }}')"
>
<span class="mr-2">{{ lang.flag }}</span>
<span>{{ lang.native }}</span>
{% if current_locale == lang.code %}
<i class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
{% endif %}
</button>
{% endfor %}
</div>
</div>
</div>
<!-- Dark mode toggle -->
<button
id="darkModeToggle"
onclick="toggleDarkMode()"
type="button"
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-blue-500"
aria-label="Toggle dark mode"
title="Toggle dark mode"
aria-label="{{ _('nav.toggle_dark_mode') }}"
title="{{ _('nav.toggle_dark_mode') }}"
>
<i class="fas fa-moon"></i>
</button>
@@ -235,10 +286,10 @@
type="button"
class="md:hidden inline-flex items-center justify-center p-3 rounded-md text-gray-400 hover:text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
style="min-height:44px;min-width:44px;"
aria-label="Toggle navigation menu"
aria-label="{{ _('nav.toggle_nav') }}"
:aria-expanded="mobileMenuOpen"
>
<span class="sr-only">Open main menu</span>
<span class="sr-only">{{ _("nav.open_main_menu") }}</span>
<svg class="block h-6 w-6" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16" />
</svg>
@@ -262,12 +313,12 @@
<a href="/pricing"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/pricing' %}aria-current="page"{% endif %}>
<i class="fas fa-tag mr-2 text-gray-400" aria-hidden="true"></i>Pricing
<i class="fas fa-tag mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.pricing") }}
</a>
<a href="/about"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/about' %}aria-current="page"{% endif %}>
<i class="fas fa-info-circle mr-2 text-gray-400" aria-hidden="true"></i>About
<i class="fas fa-info-circle mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.about") }}
</a>
{% else %}
@@ -275,87 +326,87 @@
<a href="/"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/' %}aria-current="page"{% endif %}>
<i class="fas fa-home mr-2 text-gray-400" aria-hidden="true"></i>Dashboard
<i class="fas fa-home mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.dashboard") }}
</a>
<a href="/upload"
class="block px-3 py-3 rounded-md text-base font-medium text-blue-600 hover:text-blue-800 hover:bg-blue-50"
{% if request and request.url.path == '/upload' %}aria-current="page"{% endif %}>
<i class="fas fa-upload mr-2" aria-hidden="true"></i>Upload
<i class="fas fa-upload mr-2" aria-hidden="true"></i>{{ _("nav.upload") }}
</a>
<a href="/files"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/files' %}aria-current="page"{% endif %}>
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i>Files
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.files") }}
</a>
<a href="/search"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/search' %}aria-current="page"{% endif %}>
<i class="fas fa-search mr-2 text-gray-400" aria-hidden="true"></i>Search
<i class="fas fa-search mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.search") }}
</a>
<a href="/pipelines"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/pipelines' %}aria-current="page"{% endif %}>
<i class="fas fa-project-diagram mr-2 text-gray-400" aria-hidden="true"></i>Pipelines
<i class="fas fa-project-diagram mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.pipelines") }}
</a>
<a href="/integrations"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/integrations' %}aria-current="page"{% endif %}>
<i class="fas fa-plug mr-2 text-gray-400" aria-hidden="true"></i>Integrations
<i class="fas fa-plug mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.integrations") }}
</a>
<a href="/notifications"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/notifications' %}aria-current="page"{% endif %}>
<i class="fas fa-bell mr-2 text-gray-400" aria-hidden="true"></i>Notifications
<i class="fas fa-bell mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.notifications") }}
</a>
<!-- Admin section in mobile menu shown only for admin users via JS -->
<div id="mobileAdminSection" class="hidden">
<div class="border-t border-gray-200 mt-1 pt-1">
<p class="px-3 py-1 text-xs font-semibold text-red-600 uppercase tracking-wider flex items-center">
<i class="fas fa-shield-alt mr-1" aria-hidden="true"></i> Admin
<i class="fas fa-shield-alt mr-1" aria-hidden="true"></i> {{ _("nav.admin") }}
</p>
<a href="/settings" 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-cog mr-2 text-gray-400" aria-hidden="true"></i> Settings
<i class="fas fa-cog mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.settings") }}
</a>
<a href="/admin/users" 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-users mr-2 text-blue-400" aria-hidden="true"></i> Users
<i class="fas fa-users mr-2 text-blue-400" aria-hidden="true"></i> {{ _("nav.users") }}
</a>
<a href="/admin/plans" 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-layer-group mr-2 text-indigo-400" aria-hidden="true"></i> Plan Designer
<i class="fas fa-layer-group mr-2 text-indigo-400" aria-hidden="true"></i> {{ _("nav.plan_designer") }}
</a>
<a href="/admin/credentials" 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-key mr-2 text-yellow-400" aria-hidden="true"></i> Credentials
<i class="fas fa-key mr-2 text-yellow-400" aria-hidden="true"></i> {{ _("nav.credentials") }}
</a>
<a href="/admin/files" 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-folder-open mr-2 text-gray-400" aria-hidden="true"></i> File Manager
<i class="fas fa-folder-open mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.file_manager") }}
</a>
<a href="/duplicates" 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-clone mr-2 text-orange-400" aria-hidden="true"></i> Duplicates
<i class="fas fa-clone mr-2 text-orange-400" aria-hidden="true"></i> {{ _("nav.duplicates") }}
</a>
<a href="/similarity" 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-sitemap mr-2 text-purple-400" aria-hidden="true"></i> Similarity
<i class="fas fa-sitemap mr-2 text-purple-400" aria-hidden="true"></i> {{ _("nav.similarity") }}
</a>
<a href="/admin/queue" 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-stream mr-2 text-blue-400" aria-hidden="true"></i> Queue Monitor
<i class="fas fa-stream mr-2 text-blue-400" aria-hidden="true"></i> {{ _("nav.queue_monitor") }}
</a>
<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> Scheduled Jobs
<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> Backup &amp; Restore
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
</a>
<a href="/status" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
<i class="fas fa-circle-dot mr-2 text-gray-400" aria-hidden="true"></i> Status
<i class="fas fa-circle-dot mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.status") }}
</a>
<a href="/admin/api-docs" 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-code mr-2 text-teal-400" aria-hidden="true"></i> API Docs
<i class="fas fa-code mr-2 text-teal-400" aria-hidden="true"></i> {{ _("nav.api_docs") }}
</a>
<a href="/developer-docs/" 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-book mr-2 text-teal-400" aria-hidden="true"></i> Developer Docs
<i class="fas fa-book mr-2 text-teal-400" aria-hidden="true"></i> {{ _("nav.developer_docs") }}
</a>
</div>
</div>
@@ -365,9 +416,9 @@
<!-- Help always visible, for every visitor regardless of auth state -->
<a href="/help"
class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
aria-label="Help Center"
aria-label="{{ _('nav.help_center') }}"
{% if request and request.url.path == '/help' %}aria-current="page"{% endif %}>
<i class="fas fa-circle-question mr-2 text-gray-400" aria-hidden="true"></i>Help
<i class="fas fa-circle-question mr-2 text-gray-400" aria-hidden="true"></i>{{ _("nav.help") }}
</a>
<!-- Dark mode toggle (mobile) -->
@@ -376,10 +427,10 @@
type="button"
class="flex items-center w-full px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
id="darkModeToggleMobile"
aria-label="Toggle dark mode"
aria-label="{{ _('nav.toggle_dark_mode') }}"
>
<i class="fas fa-moon mr-2" id="darkModeIconMobile" aria-hidden="true"></i>
<span id="darkModeTextMobile">Dark Mode</span>
<span id="darkModeTextMobile">{{ _("nav.dark_mode") }}</span>
</button>
<!-- Mobile Auth / Account section (populated by common.js) -->
@@ -400,14 +451,14 @@
<!-- Footer -->
<footer class="bg-white shadow" role="contentinfo">
<div class="max-w-7xl mx-auto px-4 py-4 text-center text-gray-600">
DocuElevate 2025 -
<nav aria-label="Footer navigation" class="inline">
<a href="/privacy" class="text-blue-500 hover:underline">Privacy</a> -
<a href="/imprint" class="text-blue-500 hover:underline">Imprint</a> -
<a href="/terms" class="text-blue-500 hover:underline">Terms</a> -
<a href="/cookies" class="text-blue-500 hover:underline">Cookies</a> -
<a href="/license" class="text-blue-500 hover:underline">License</a> -
<a href="/attribution" class="text-blue-500 hover:underline">Attributions</a>
{{ _("footer.copyright", year="2025") }} -
<nav aria-label="{{ _('footer.navigation') }}" class="inline">
<a href="/privacy" class="text-blue-500 hover:underline">{{ _("footer.privacy") }}</a> -
<a href="/imprint" class="text-blue-500 hover:underline">{{ _("footer.imprint") }}</a> -
<a href="/terms" class="text-blue-500 hover:underline">{{ _("footer.terms") }}</a> -
<a href="/cookies" class="text-blue-500 hover:underline">{{ _("footer.cookies") }}</a> -
<a href="/license" class="text-blue-500 hover:underline">{{ _("footer.license") }}</a> -
<a href="/attribution" class="text-blue-500 hover:underline">{{ _("footer.attributions") }}</a>
</nav> -
<span class="text-xs">Version {{ app_version|default(version, true) }}{% if release_name %} "{{ release_name }}"{% endif %}</span>
</div>
@@ -422,19 +473,18 @@
style="display:none!important"
aria-live="polite"
role="region"
aria-label="Cookie notice">
aria-label="{{ _('cookie.notice_label') }}">
<p class="text-center sm:text-left">
DocuElevate uses only essential session cookies required for authentication and service operation.
No tracking or analytics cookies are used.
<a href="/cookies" class="underline hover:text-blue-300 ml-1">Cookie Policy</a> &middot;
<a href="/privacy" class="underline hover:text-blue-300 ml-1">Privacy Notice</a>
{{ _("cookie.notice") }}
<a href="/cookies" class="underline hover:text-blue-300 ml-1">{{ _("cookie.policy_link") }}</a> &middot;
<a href="/privacy" class="underline hover:text-blue-300 ml-1">{{ _("cookie.privacy_link") }}</a>
</p>
<button
id="cookieNoticeAccept"
type="button"
class="flex-shrink-0 bg-blue-600 hover:bg-blue-700 text-white font-semibold px-4 py-1.5 rounded focus:outline-none focus:ring-2 focus:ring-blue-400"
aria-label="Acknowledge cookie notice">
Got it
aria-label="{{ _('cookie.accept') }}">
{{ _("cookie.accept") }}
</button>
</div>
<script>
@@ -457,6 +507,26 @@
<!-- Common JS (shared) -->
<script src="/static/js/common.js"></script>
<!-- Language selector -->
<script>
function setLanguage(langCode) {
var csrfToken = document.querySelector('meta[name="csrf-token"]');
var headers = { 'Content-Type': 'application/json' };
if (csrfToken && csrfToken.content) {
headers['X-CSRF-Token'] = csrfToken.content;
}
fetch('/api/i18n/language', {
method: 'POST',
headers: headers,
body: JSON.stringify({ language: langCode })
}).then(function() {
window.location.reload();
}).catch(function() {
window.location.reload();
});
}
</script>
<!-- Notification badge updater -->
<script>
(function() {