fix: merge main, address code review feedback for security fix PR #816
- Merge origin/main into branch (resolve conflict in integrations_dashboard.html) - Add defensive JSON parsing with try/except for integration.config - Wrap tester() call in try/except to prevent 500 errors from bad config - Add i18n key integrations.connection_test_failed_fallback in en.json - Reference i18n key in template JS fallback message - Update SECURITY_AUDIT.md: add fix date (2026-03-23), update doc date - Remove accidental revert.sh file - Fix missing MagicMock/patch imports in test file - Add tests for invalid JSON config and tester exception error paths Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/daebb70e-059a-4601-8864-88eef49f99cf
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
{% 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 %}
|
||||
@@ -33,6 +33,20 @@
|
||||
aria-required="true"
|
||||
/>
|
||||
</div>
|
||||
<div class="sm:w-44">
|
||||
<label for="token-lifetime" class="sr-only">{{ _("api_tokens.expires_in_days_label") }}</label>
|
||||
<input
|
||||
id="token-lifetime"
|
||||
type="number"
|
||||
x-model.number="newTokenExpiresDays"
|
||||
placeholder="{{ _('api_tokens.expires_at_placeholder') }}"
|
||||
min="1"
|
||||
max="3650"
|
||||
class="w-full px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:bg-gray-700 dark:text-white text-sm"
|
||||
aria-label="{{ _('api_tokens.expires_in_days_label') }}"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="creating || !newTokenName.trim()"
|
||||
@@ -143,6 +157,7 @@
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_created") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_used") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_last_ip") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("api_tokens.col_expires") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("common.status") }}</th>
|
||||
<th scope="col" class="px-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
|
||||
</tr>
|
||||
@@ -162,28 +177,62 @@
|
||||
<code x-show="token.last_used_ip" class="bg-gray-100 dark:bg-gray-700 px-2 py-0.5 rounded text-xs font-mono" x-text="token.last_used_ip"></code>
|
||||
<span x-show="!token.last_used_ip" class="text-gray-400">—</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
<span x-text="token.expires_at ? formatDate(token.expires_at) : '{{ _('api_tokens.expires_never') }}'"></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
|
||||
x-text="token.is_active ? '{{ _('api_tokens.status_active') }}' : '{{ _('api_tokens.status_revoked') }}'"
|
||||
:class="tokenStatusClass(token)"
|
||||
x-text="tokenStatusLabel(token)"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right">
|
||||
<!-- Revoke button – shown for active tokens -->
|
||||
<button
|
||||
x-show="token.is_active"
|
||||
type="button"
|
||||
@click="revokeToken(token)"
|
||||
:disabled="revoking === token.id"
|
||||
:disabled="acting === token.id"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'{{ _('api_tokens.revoke_prefix') }} ' + token.name"
|
||||
>
|
||||
<i :class="revoking === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-ban'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("api_tokens.revoke") }}
|
||||
</button>
|
||||
<!-- Reactivate button – shown for revoked tokens -->
|
||||
<button
|
||||
x-show="!token.is_active"
|
||||
type="button"
|
||||
@click="reactivateToken(token)"
|
||||
:disabled="acting === token.id"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-green-600 hover:text-green-800
|
||||
dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 transition-colors mr-1"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'{{ _('api_tokens.reactivate_prefix') }} ' + token.name"
|
||||
>
|
||||
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-redo'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("api_tokens.reactivate") }}
|
||||
</button>
|
||||
<!-- Delete button – shown for revoked tokens -->
|
||||
<button
|
||||
x-show="!token.is_active"
|
||||
type="button"
|
||||
@click="deleteToken(token)"
|
||||
:disabled="acting === token.id"
|
||||
class="inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'{{ _('api_tokens.delete_prefix') }} ' + token.name"
|
||||
>
|
||||
<i :class="acting === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("api_tokens.delete") }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
@@ -209,9 +258,10 @@ function apiTokens() {
|
||||
tokens: [],
|
||||
loading: true,
|
||||
creating: false,
|
||||
revoking: null,
|
||||
acting: null,
|
||||
error: null,
|
||||
newTokenName: '',
|
||||
newTokenExpiresDays: null,
|
||||
newlyCreatedToken: null,
|
||||
copied: false,
|
||||
baseUrl: window.location.origin,
|
||||
@@ -238,13 +288,15 @@ function apiTokens() {
|
||||
this.error = null;
|
||||
this.newlyCreatedToken = null;
|
||||
try {
|
||||
const body = { name: this.newTokenName.trim() };
|
||||
if (this.newTokenExpiresDays) body.expires_in_days = parseInt(this.newTokenExpiresDays);
|
||||
const res = await fetch('/api/api-tokens/', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-Token': csrfToken,
|
||||
},
|
||||
body: JSON.stringify({ name: this.newTokenName.trim() }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
@@ -253,6 +305,7 @@ function apiTokens() {
|
||||
const data = await res.json();
|
||||
this.newlyCreatedToken = data.token;
|
||||
this.newTokenName = '';
|
||||
this.newTokenExpiresDays = null;
|
||||
await this.loadTokens();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
@@ -263,7 +316,7 @@ function apiTokens() {
|
||||
|
||||
async revokeToken(token) {
|
||||
if (!confirm(`Revoke token "${token.name}"? This cannot be undone.`)) return;
|
||||
this.revoking = token.id;
|
||||
this.acting = token.id;
|
||||
this.error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||
@@ -278,10 +331,66 @@ function apiTokens() {
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.revoking = null;
|
||||
this.acting = null;
|
||||
}
|
||||
},
|
||||
|
||||
async reactivateToken(token) {
|
||||
if (!confirm({{ _("api_tokens.reactivate_confirm") | tojson }})) return;
|
||||
this.acting = token.id;
|
||||
this.error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to reactivate token');
|
||||
}
|
||||
await this.loadTokens();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.acting = null;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteToken(token) {
|
||||
if (!confirm({{ _("api_tokens.delete_confirm") | tojson }})) return;
|
||||
this.acting = token.id;
|
||||
this.error = null;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to delete token');
|
||||
}
|
||||
await this.loadTokens();
|
||||
} catch (e) {
|
||||
this.error = e.message;
|
||||
} finally {
|
||||
this.acting = null;
|
||||
}
|
||||
},
|
||||
|
||||
tokenStatusClass(token) {
|
||||
if (!token.is_active) return 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400';
|
||||
if (token.expires_at && new Date(token.expires_at) < new Date())
|
||||
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
return 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400';
|
||||
},
|
||||
|
||||
tokenStatusLabel(token) {
|
||||
if (!token.is_active) return '{{ _("api_tokens.status_revoked") }}';
|
||||
if (token.expires_at && new Date(token.expires_at) < new Date())
|
||||
return '{{ _("api_tokens.status_expired") }}';
|
||||
return '{{ _("api_tokens.status_active") }}';
|
||||
},
|
||||
|
||||
copyToken() {
|
||||
if (this.newlyCreatedToken) {
|
||||
navigator.clipboard.writeText(this.newlyCreatedToken);
|
||||
|
||||
@@ -21,16 +21,45 @@
|
||||
<!-- Alpine.js moved to head for earlier loading -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||
{% block head_css %}
|
||||
<!-- Tailwind CSS and other CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<!-- flag-icons: SVG/CSS flag sprites that render correctly on all platforms including Windows -->
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/flag-icons@7.3.2/css/flag-icons.min.css"
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
{% endblock %}
|
||||
{% block head_extra %}{% endblock %}
|
||||
<!-- CSRF token for AJAX/fetch requests -->
|
||||
<meta name="csrf-token" content="{{ csrf_token | default('', true) }}">
|
||||
{# ── Sentry Browser SDK ─────────────────────────────────────────────────
|
||||
Loaded only when SENTRY_DSN is configured. The DSN is a *public*
|
||||
Sentry key and is intentionally embedded in client-side code.
|
||||
Update the version pin at browser.sentry-cdn.com/releases when
|
||||
upgrading the SDK.
|
||||
──────────────────────────────────────────────────────────────────────── #}
|
||||
{% if sentry_dsn %}
|
||||
<script src="https://browser.sentry-cdn.com/10.45.0/bundle.tracing.replay.feedback.logs.metrics.min.js"
|
||||
integrity="sha384-TCY3xw5Ej940LIWfS6PwhCCBl7lvEsxBpHy+BirF+EycSQUvXbfZsgsLi0oU18yZ"
|
||||
crossorigin="anonymous"
|
||||
></script>
|
||||
<script>
|
||||
if (window.Sentry) {
|
||||
Sentry.init({
|
||||
dsn: {{ sentry_dsn | tojson }},
|
||||
environment: {{ sentry_environment | default("production") | tojson }},
|
||||
release: {{ version | default(None) | tojson }},
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration(),
|
||||
],
|
||||
tracesSampleRate: {{ sentry_js_traces_sample_rate | default(0.0) }},
|
||||
replaysSessionSampleRate: {{ sentry_js_replay_session_sample_rate | default(0.0) }},
|
||||
replaysOnErrorSampleRate: {{ sentry_js_replay_on_error_sample_rate | default(0.1) }},
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
</head>
|
||||
|
||||
<body class="bg-gray-50 min-h-screen flex flex-col"
|
||||
@@ -152,6 +181,9 @@
|
||||
<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> {{ _("nav.credentials") }}
|
||||
</a>
|
||||
<a href="/admin/connections" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
|
||||
<i class="fas fa-plug w-4 mr-2 text-purple-500" aria-hidden="true"></i> {{ _("nav.connections") }}
|
||||
</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> {{ _("nav.file_manager") }}
|
||||
</a>
|
||||
@@ -174,6 +206,11 @@
|
||||
<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>
|
||||
{% if enable_factory_reset %}
|
||||
<a href="/admin/system-reset" role="menuitem" class="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50">
|
||||
<i class="fas fa-skull-crossbones w-4 mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<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>
|
||||
@@ -250,7 +287,7 @@
|
||||
>
|
||||
<span class="text-base leading-none" aria-hidden="true">
|
||||
{% set cur_lang = suggested_languages | selectattr("code", "equalto", current_locale) | list %}
|
||||
{% if cur_lang %}{{ cur_lang[0].flag }}{% else %}🌐{% endif %}
|
||||
{% if cur_lang %}<span class="fi fi-{{ cur_lang[0].flag }}"></span>{% else %}<i class="fas fa-globe"></i>{% endif %}
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
@@ -290,7 +327,7 @@
|
||||
class="flex items-center w-full px-4 py-2 text-sm text-left hover:bg-gray-100 dark:hover:bg-gray-700"
|
||||
:onclick="`setLanguage('${lang.code}')`"
|
||||
>
|
||||
<span class="mr-2 text-base" x-text="lang.flag"></span>
|
||||
<span class="fi mr-2" :class="`fi-${lang.flag}`" aria-hidden="true"></span>
|
||||
<span x-text="lang.native"></span>
|
||||
<i x-show="lang.code === '{{ current_locale }}'" class="fas fa-check ml-auto text-blue-500" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -421,6 +458,9 @@
|
||||
<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> {{ _("nav.credentials") }}
|
||||
</a>
|
||||
<a href="/admin/connections" 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-plug mr-2 text-purple-400" aria-hidden="true"></i> {{ _("nav.connections") }}
|
||||
</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> {{ _("nav.file_manager") }}
|
||||
</a>
|
||||
@@ -442,6 +482,11 @@
|
||||
<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>
|
||||
{% if enable_factory_reset %}
|
||||
<a href="/admin/system-reset" class="block px-3 py-3 rounded-md text-base font-medium text-red-600 hover:text-red-800 hover:bg-red-50">
|
||||
<i class="fas fa-skull-crossbones mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
|
||||
</a>
|
||||
{% endif %}
|
||||
<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> {{ _("nav.status") }}
|
||||
@@ -557,6 +602,7 @@
|
||||
profileSettings: {{ _("nav.profile_settings") | tojson }},
|
||||
mySubscription: {{ _("nav.my_subscription") | tojson }},
|
||||
apiTokens: {{ _("nav.api_tokens") | tojson }},
|
||||
devices: {{ _("nav.devices") | tojson }},
|
||||
sharedLinks: {{ _("nav.shared_links") | tojson }},
|
||||
signOut: {{ _("nav.sign_out") | tojson }},
|
||||
logIn: {{ _("nav.login") | tojson }},
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ _("billing.success_page_title") }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
@@ -0,0 +1,459 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ _("devices.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-6xl">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<header class="mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
|
||||
<i class="fas fa-mobile-alt text-blue-500" aria-hidden="true"></i>
|
||||
{{ _("devices.heading") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
|
||||
{{ _("devices.intro") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- ── Mobile App Tokens ──────────────────────────────────────────────── -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="mobile-tokens-heading">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 id="mobile-tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-key text-yellow-500 mr-2" aria-hidden="true"></i>{{ _("devices.mobile_tokens_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.mobile_tokens_description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<template x-if="loadingTokens">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||||
<p class="text-sm">{{ _("devices.loading") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!loadingTokens && mobileTokens.length === 0">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-mobile-alt text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||||
<p class="font-medium">{{ _("devices.no_mobile_tokens") }}</p>
|
||||
<p class="text-sm mt-1">{{ _("devices.no_mobile_tokens_help") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Tokens table -->
|
||||
<template x-if="!loadingTokens && mobileTokens.length > 0">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm" aria-label="{{ _('devices.mobile_tokens_heading') }}">
|
||||
<thead>
|
||||
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
|
||||
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
|
||||
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="token in mobileTokens" :key="token.id">
|
||||
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<div class="flex items-center gap-2">
|
||||
<i class="fas fa-mobile-alt text-gray-400" aria-hidden="true"></i>
|
||||
<span class="font-medium text-gray-900 dark:text-white" x-text="formatDeviceName(token.name)"></span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
|
||||
<span x-text="token.last_used_at ? formatDate(token.last_used_at) : '—'"></span>
|
||||
<span x-show="token.last_used_ip" class="block text-xs text-gray-400 mt-0.5">
|
||||
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="token.last_used_ip"></span>
|
||||
</span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap">
|
||||
<span
|
||||
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
|
||||
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
|
||||
x-text="token.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_revoked') }}'"
|
||||
></span>
|
||||
</td>
|
||||
<td class="px-4 py-3 whitespace-nowrap text-right">
|
||||
<div class="inline-flex items-center gap-1">
|
||||
<!-- Revoke button – shown for active tokens -->
|
||||
<button
|
||||
x-show="token.is_active"
|
||||
type="button"
|
||||
@click="revokeToken(token)"
|
||||
:disabled="actingToken === token.id"
|
||||
:title="'{{ _('devices.revoke_token') }}'"
|
||||
class="inline-flex items-center justify-center w-11 h-11 text-sm text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
:aria-label="'{{ _('devices.revoke_token') }} ' + token.name"
|
||||
>
|
||||
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-sign-out-alt'" aria-hidden="true"></i>
|
||||
</button>
|
||||
<!-- Reactivate button – shown for revoked tokens -->
|
||||
<button
|
||||
x-show="!token.is_active"
|
||||
type="button"
|
||||
@click="reactivateMobileToken(token)"
|
||||
:disabled="actingToken === token.id"
|
||||
:title="'{{ _('devices.reactivate_token') }}'"
|
||||
class="inline-flex items-center justify-center w-11 h-11 text-sm text-green-600 hover:text-green-800
|
||||
dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 transition-colors"
|
||||
:aria-label="'{{ _('devices.reactivate_token') }} ' + token.name"
|
||||
>
|
||||
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-redo'" aria-hidden="true"></i>
|
||||
</button>
|
||||
<!-- Delete button – shown for revoked tokens -->
|
||||
<button
|
||||
x-show="!token.is_active"
|
||||
type="button"
|
||||
@click="deleteMobileToken(token)"
|
||||
:disabled="actingToken === token.id"
|
||||
:title="'{{ _('devices.delete_token') }}'"
|
||||
class="inline-flex items-center justify-center w-11 h-11 text-sm text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
:aria-label="'{{ _('devices.delete_token') }} ' + token.name"
|
||||
>
|
||||
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<template x-if="tokenError">
|
||||
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
<span x-text="tokenError"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── Registered Devices (Push Notifications) ────────────────────────── -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="devices-heading">
|
||||
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h2 id="devices-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-bell text-purple-500 mr-2" aria-hidden="true"></i>{{ _("devices.registered_devices_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.registered_devices_description") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<template x-if="loadingDevices">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
|
||||
<p class="text-sm">{{ _("devices.loading") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Empty state -->
|
||||
<template x-if="!loadingDevices && devices.length === 0">
|
||||
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
|
||||
<i class="fas fa-bell-slash text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
|
||||
<p class="font-medium">{{ _("devices.no_devices") }}</p>
|
||||
<p class="text-sm mt-1">{{ _("devices.no_devices_help") }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Devices list -->
|
||||
<template x-if="!loadingDevices && devices.length > 0">
|
||||
<div class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<template x-for="device in devices" :key="device.id">
|
||||
<div class="flex items-center justify-between px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<i
|
||||
:class="device.platform === 'ios' ? 'fab fa-apple' :
|
||||
device.platform === 'android' ? 'fab fa-android text-green-500' :
|
||||
'fas fa-globe'"
|
||||
class="text-lg text-gray-400 flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<span x-text="device.device_name || 'Unknown Device'"></span>
|
||||
<span
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
|
||||
:class="device.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
|
||||
x-text="device.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_inactive') }}'"
|
||||
></span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3 mt-0.5">
|
||||
<span>
|
||||
<i class="fas fa-microchip mr-1" aria-hidden="true"></i>
|
||||
<span x-text="device.platform.charAt(0).toUpperCase() + device.platform.slice(1)"></span>
|
||||
</span>
|
||||
<span x-show="device.last_seen_at">
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("devices.col_last_seen") }}:
|
||||
<span x-text="formatDate(device.last_seen_at)"></span>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-calendar mr-1" aria-hidden="true"></i>
|
||||
<span x-text="formatDate(device.created_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
x-show="device.is_active"
|
||||
type="button"
|
||||
@click="deactivateDevice(device)"
|
||||
:disabled="actingDevice === device.id"
|
||||
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'{{ _('devices.deactivate_device') }} ' + (device.device_name || 'device')"
|
||||
>
|
||||
<i :class="actingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-power-off'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("devices.deactivate_device") }}
|
||||
</button>
|
||||
<!-- Delete button – shown for already-inactive devices -->
|
||||
<button
|
||||
x-show="!device.is_active"
|
||||
type="button"
|
||||
@click="deleteDevice(device)"
|
||||
:disabled="actingDevice === device.id"
|
||||
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
|
||||
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
|
||||
style="min-height:36px; min-width:44px;"
|
||||
:aria-label="'{{ _('devices.delete_device') }} ' + (device.device_name || 'device')"
|
||||
>
|
||||
<i :class="actingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("devices.delete_device") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error -->
|
||||
<template x-if="deviceError">
|
||||
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
|
||||
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
|
||||
<span x-text="deviceError"></span>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── QR Login CTA ───────────────────────────────────────────────────── -->
|
||||
<div class="text-center">
|
||||
<a
|
||||
href="/qr-login"
|
||||
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
|
||||
rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
|
||||
{{ _("devices.qr_login_cta") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- ── Status banner ──────────────────────────────────────────────────── -->
|
||||
<div
|
||||
x-show="banner.visible"
|
||||
x-transition
|
||||
class="mt-6 rounded-lg p-3 text-sm"
|
||||
:class="banner.error
|
||||
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
|
||||
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span x-text="banner.message"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function devicesPage() {
|
||||
const csrfToken = '{{ csrf_token | default("") }}';
|
||||
return {
|
||||
mobileTokens: [],
|
||||
devices: [],
|
||||
loadingTokens: true,
|
||||
loadingDevices: true,
|
||||
actingToken: null,
|
||||
actingDevice: null,
|
||||
tokenError: null,
|
||||
deviceError: null,
|
||||
banner: { visible: false, error: false, message: '' },
|
||||
|
||||
async init() {
|
||||
await Promise.all([this.loadMobileTokens(), this.loadDevices()]);
|
||||
},
|
||||
|
||||
async loadMobileTokens() {
|
||||
this.loadingTokens = true;
|
||||
this.tokenError = null;
|
||||
try {
|
||||
const res = await fetch('/api/api-tokens/mobile', {
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load mobile tokens');
|
||||
this.mobileTokens = await res.json();
|
||||
} catch (e) {
|
||||
this.tokenError = e.message;
|
||||
} finally {
|
||||
this.loadingTokens = false;
|
||||
}
|
||||
},
|
||||
|
||||
async loadDevices() {
|
||||
this.loadingDevices = true;
|
||||
this.deviceError = null;
|
||||
try {
|
||||
const res = await fetch('/api/mobile/devices', {
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) throw new Error('Failed to load devices');
|
||||
this.devices = await res.json();
|
||||
} catch (e) {
|
||||
this.deviceError = e.message;
|
||||
} finally {
|
||||
this.loadingDevices = false;
|
||||
}
|
||||
},
|
||||
|
||||
async revokeToken(token) {
|
||||
if (!confirm({{ _("devices.confirm_revoke_token") | tojson }})) return;
|
||||
this.actingToken = token.id;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to revoke token');
|
||||
}
|
||||
await this.loadMobileTokens();
|
||||
this._showBanner({{ _("devices.token_revoked_success") | tojson }}, false);
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.actingToken = null;
|
||||
}
|
||||
},
|
||||
|
||||
async reactivateMobileToken(token) {
|
||||
if (!confirm({{ _("devices.reactivate_token_confirm") | tojson }})) return;
|
||||
this.actingToken = token.id;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, {
|
||||
method: 'POST',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to reactivate token');
|
||||
}
|
||||
await this.loadMobileTokens();
|
||||
this._showBanner({{ _("devices.token_reactivated_success") | tojson }}, false);
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.actingToken = null;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteMobileToken(token) {
|
||||
if (!confirm({{ _("devices.delete_token_confirm") | tojson }})) return;
|
||||
this.actingToken = token.id;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to delete token');
|
||||
}
|
||||
await this.loadMobileTokens();
|
||||
this._showBanner({{ _("devices.token_deleted_success") | tojson }}, false);
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.actingToken = null;
|
||||
}
|
||||
},
|
||||
|
||||
async deactivateDevice(device) {
|
||||
if (!confirm({{ _("devices.confirm_deactivate_device") | tojson }})) return;
|
||||
this.actingDevice = device.id;
|
||||
try {
|
||||
const res = await fetch(`/api/mobile/devices/${device.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to remove device');
|
||||
}
|
||||
await this.loadDevices();
|
||||
this._showBanner({{ _("devices.device_removed_success") | tojson }}, false);
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.actingDevice = null;
|
||||
}
|
||||
},
|
||||
|
||||
async deleteDevice(device) {
|
||||
if (!confirm({{ _("devices.delete_device_confirm") | tojson }})) return;
|
||||
this.actingDevice = device.id;
|
||||
try {
|
||||
const res = await fetch(`/api/mobile/devices/${device.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to delete device');
|
||||
}
|
||||
await this.loadDevices();
|
||||
this._showBanner({{ _("devices.device_deleted_success") | tojson }}, false);
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.actingDevice = null;
|
||||
}
|
||||
},
|
||||
|
||||
/** Extract the device name from the full token name (e.g. "Mobile App – iPhone 15 Pro" → "iPhone 15 Pro"). */
|
||||
formatDeviceName(name) {
|
||||
if (!name) return 'Unknown Device';
|
||||
// Match either em dash (–) or hyphen (-) separators used by the mobile flows.
|
||||
const match = name.match(/[–\-]\s*(.+)$/);
|
||||
return match ? match[1].trim() : name;
|
||||
},
|
||||
|
||||
formatDate(d) {
|
||||
if (!d) return '—';
|
||||
const dt = new Date(d);
|
||||
return dt.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
|
||||
' ' + dt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
||||
},
|
||||
|
||||
_showBanner(msg, isError) {
|
||||
this.banner = { visible: true, error: isError, message: msg };
|
||||
setTimeout(() => { this.banner.visible = false; }, 5000);
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -104,7 +104,7 @@
|
||||
<h3 class="text-xl font-medium mb-4">Step 3: Set OAuth 2 Redirect URI</h3>
|
||||
<ol class="list-decimal ml-6 space-y-3">
|
||||
<li>In your app's settings page, go to the "OAuth 2" section</li>
|
||||
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ request.url.scheme }}://{{ request.url.netloc }}/dropbox-callback</code></li>
|
||||
<li>Add a redirect URI: <code class="bg-gray-100 p-1">{{ callback_url }}</code></li>
|
||||
<li>Click "Add" to save the redirect URI</li>
|
||||
</ol>
|
||||
</div>
|
||||
@@ -116,6 +116,34 @@
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
{% if user_mode and global_creds_available %}
|
||||
<!-- Global credentials mode: no app credentials required from the user -->
|
||||
<div class="bg-blue-50 border border-blue-200 rounded-md p-4" role="note">
|
||||
<div class="flex items-start">
|
||||
<svg class="h-5 w-5 text-blue-400 mt-0.5 mr-3 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
<div>
|
||||
<p class="text-sm font-medium text-blue-800">Using shared application credentials</p>
|
||||
<p class="text-sm text-blue-700 mt-1">Your administrator has enabled shared Dropbox app credentials. You can authorize your account without supplying your own App Key and Secret.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if folder_path %}
|
||||
<div class="bg-gray-50 border border-gray-200 rounded-md px-3 py-2">
|
||||
<p class="text-xs text-gray-500">Target folder (from integration settings)</p>
|
||||
<p class="text-sm font-mono text-gray-700">{{ folder_path }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<button id="start-auth-flow-global" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||
<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>
|
||||
Authorize with Dropbox
|
||||
</button>
|
||||
</div>
|
||||
{% else %}
|
||||
<div>
|
||||
<label for="app-key" class="block text-sm font-medium text-gray-700">App Key <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="app-key" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Dropbox app key" value="{{ app_key_value }}">
|
||||
@@ -146,6 +174,7 @@
|
||||
Start Authentication Flow
|
||||
</button>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Token validation and status (admin mode only) -->
|
||||
{% if not user_mode %}
|
||||
@@ -268,6 +297,10 @@ DROPBOX_FOLDER={{ folder_path|default('/Documents/Uploads', true) }}</code></pre
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||
const globalCredsAvailable = {{ 'true' if global_creds_available else 'false' }};
|
||||
// Redirect URI for OAuth: prefer server-provided value (respects PUBLIC_BASE_URL),
|
||||
// fall back to window.location.origin for resilience.
|
||||
const dropboxCallbackUrl = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
|
||||
|
||||
// Store integration_id if provided (for per-user OAuth flow)
|
||||
const integrationId = "{{ integration_id or '' }}";
|
||||
@@ -277,6 +310,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Elements
|
||||
const startAuthFlowBtn = document.getElementById('start-auth-flow');
|
||||
const startAuthFlowGlobalBtn = document.getElementById('start-auth-flow-global');
|
||||
const testTokenBtn = document.getElementById('test-token');
|
||||
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
||||
const tokenStatus = document.getElementById('token-status');
|
||||
@@ -328,11 +362,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Global-credentials "Authorize with Dropbox" button (user mode, admin-provided creds)
|
||||
if (startAuthFlowGlobalBtn) {
|
||||
startAuthFlowGlobalBtn.addEventListener('click', async function() {
|
||||
startAuthFlowGlobalBtn.disabled = true;
|
||||
startAuthFlowGlobalBtn.innerHTML = '<span class="animate-spin inline-block mr-2">⟳</span> Redirecting…';
|
||||
try {
|
||||
const resp = await fetch('/api/dropbox/global-authorize-url');
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json().catch(() => ({}));
|
||||
showModal('error', 'Error', err.detail || 'Could not retrieve authorization URL.');
|
||||
startAuthFlowGlobalBtn.disabled = false;
|
||||
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
// Signal to the callback that global credentials should be used for the exchange
|
||||
sessionStorage.setItem('dropbox_use_global_creds', 'true');
|
||||
window.location.href = data.authorize_url;
|
||||
} catch (err) {
|
||||
showModal('error', 'Network Error', err.message || 'Unknown error');
|
||||
startAuthFlowGlobalBtn.disabled = false;
|
||||
startAuthFlowGlobalBtn.innerHTML = '<i class="fab fa-dropbox mr-2" aria-hidden="true"></i>Authorize with Dropbox';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Start Authentication Flow button click
|
||||
startAuthFlowBtn.addEventListener('click', function() {
|
||||
if (startAuthFlowBtn) {
|
||||
startAuthFlowBtn.addEventListener('click', function() {
|
||||
const appKey = document.getElementById('app-key').value.trim();
|
||||
const appSecret = appSecretInput.value.trim();
|
||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
||||
const redirectUri = dropboxCallbackUrl;
|
||||
|
||||
if (!appKey) {
|
||||
showModal('error', 'Validation Error', 'Please enter your App Key');
|
||||
@@ -362,6 +423,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect the user to the Dropbox login page
|
||||
window.location.href = authUrl;
|
||||
});
|
||||
} // end if (startAuthFlowBtn)
|
||||
|
||||
// Test Token button click (admin mode only)
|
||||
if (testTokenBtn) {
|
||||
|
||||
@@ -59,6 +59,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder selector (shown after authorization) -->
|
||||
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
|
||||
<h3 class="font-medium text-lg mb-3">
|
||||
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
|
||||
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
|
||||
</svg>
|
||||
Select Folder
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Browse your Dropbox to select a folder for this integration.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
|
||||
<div class="p-4 text-center text-gray-500">
|
||||
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
|
||||
<p class="text-sm">Loading folders…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
|
||||
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
|
||||
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Save Folder
|
||||
</button>
|
||||
</div>
|
||||
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
||||
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
@@ -94,6 +128,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const code = "{{ code }}";
|
||||
|
||||
// Get credentials from session storage (these take precedence over server-provided values)
|
||||
const useGlobalCreds = sessionStorage.getItem('dropbox_use_global_creds') === 'true';
|
||||
const appKey = sessionStorage.getItem('dropbox_app_key') || "{{ app_key_value }}";
|
||||
const appSecret = sessionStorage.getItem('dropbox_app_secret') || "{{ app_secret_value }}";
|
||||
const folderPath = sessionStorage.getItem('dropbox_folder_path') || "{{ folder_path }}" || '/Documents/Uploads';
|
||||
@@ -107,20 +142,100 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
}
|
||||
|
||||
const redirectUri = window.location.origin + "/dropbox-callback";
|
||||
// Use server-provided callback URL (respects PUBLIC_BASE_URL) with fallback to window.location.origin
|
||||
const redirectUri = {{ callback_url | tojson }} || (window.location.origin + "/dropbox-callback");
|
||||
|
||||
// Automatically exchange the code for a refresh token
|
||||
if (code) {
|
||||
if (!appKey || !appSecret) {
|
||||
showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
|
||||
return;
|
||||
if (useGlobalCreds) {
|
||||
// Global credentials: the server handles the exchange using the admin app secret
|
||||
exchangeCodeGlobal(code, redirectUri);
|
||||
} else {
|
||||
if (!appKey || !appSecret) {
|
||||
showError("Missing App Key or App Secret. Please go back to the setup page and try again.");
|
||||
return;
|
||||
}
|
||||
exchangeCode(code, appKey, appSecret, redirectUri);
|
||||
}
|
||||
|
||||
exchangeCode(code, appKey, appSecret, redirectUri);
|
||||
} else {
|
||||
showError("No authorization code was found in the URL");
|
||||
}
|
||||
|
||||
function exchangeCodeGlobal(code, redirectUri) {
|
||||
const formData = new FormData();
|
||||
formData.append('code', code);
|
||||
formData.append('redirect_uri', redirectUri);
|
||||
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p>Exchanging authorization code using shared credentials…</p>';
|
||||
|
||||
fetch('/api/dropbox/exchange-token-global', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to exchange token');
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.refresh_token) {
|
||||
const resolvedAppKey = data.app_key || '';
|
||||
if (integrationId) {
|
||||
const creds = {
|
||||
refresh_token: data.refresh_token,
|
||||
// Store public app_key with the integration (no secret stored browser-side)
|
||||
app_key: resolvedAppKey,
|
||||
// Flag so the backend knows to use global app_secret for future operations
|
||||
use_global_app_secret: true,
|
||||
};
|
||||
|
||||
const body = { credentials: creds };
|
||||
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p>Saving credentials to your integration…</p>';
|
||||
|
||||
return fetch(`/api/integrations/${integrationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error('Failed to save credentials: ' + (err.detail || 'Unknown error'));
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
}).then(() => {
|
||||
// Clean up
|
||||
sessionStorage.removeItem('dropbox_use_global_creds');
|
||||
sessionStorage.removeItem('oauth_integration_id');
|
||||
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>' +
|
||||
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations…</p>';
|
||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
||||
});
|
||||
}
|
||||
// Global admin flow — the exchange-token-global endpoint is intended for
|
||||
// per-user integrations, so reaching here without an integrationId is unexpected.
|
||||
console.warn('dropbox_callback: global creds flow reached without integration_id');
|
||||
sessionStorage.removeItem('dropbox_use_global_creds');
|
||||
showSuccess(data.refresh_token, resolvedAppKey, '', folderPath);
|
||||
setTimeout(() => { window.location.href = '/status'; }, 10000);
|
||||
} else {
|
||||
throw new Error('No refresh token was received from the server');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
showError(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
function exchangeCode(code, appKey, appSecret, redirectUri) {
|
||||
const formData = new FormData();
|
||||
formData.append('client_id', appKey);
|
||||
@@ -173,18 +288,21 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
return response.json();
|
||||
}).then(() => {
|
||||
// Clean up
|
||||
// Clean up session storage
|
||||
sessionStorage.removeItem('dropbox_app_key');
|
||||
sessionStorage.removeItem('dropbox_app_secret');
|
||||
sessionStorage.removeItem('dropbox_folder_path');
|
||||
sessionStorage.removeItem('oauth_integration_id');
|
||||
sessionStorage.removeItem('dropbox_use_system_creds');
|
||||
|
||||
// Show brief success then redirect to integrations
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>' +
|
||||
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
|
||||
// Hide processing spinner, show success
|
||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p class="text-green-600 font-semibold">✓ Dropbox authorized successfully!</p>';
|
||||
document.getElementById('success-container').classList.remove('hidden');
|
||||
|
||||
// Show folder browser with the access token
|
||||
initFolderBrowser(data.access_token, integrationId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -279,6 +397,128 @@ DROPBOX_FOLDER=${folderPath || '/Documents/Uploads'}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Folder browser ────────────────────────────────────────────────
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function initFolderBrowser(accessToken, integrationId) {
|
||||
const folderSelector = document.getElementById('folder-selector');
|
||||
if (!folderSelector || !integrationId) return;
|
||||
|
||||
folderSelector.classList.remove('hidden');
|
||||
let currentPath = '';
|
||||
|
||||
const folderList = document.getElementById('folder-list');
|
||||
const breadcrumb = document.getElementById('folder-breadcrumb');
|
||||
const selectedInput = document.getElementById('selected-folder-path');
|
||||
const saveBtn = document.getElementById('save-folder-btn');
|
||||
const saveStatus = document.getElementById('folder-save-status');
|
||||
|
||||
function loadFolders(path) {
|
||||
currentPath = path;
|
||||
folderList.innerHTML = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('access_token', accessToken);
|
||||
formData.append('path', path);
|
||||
|
||||
fetch('/api/dropbox/list-folders', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.folders && data.folders.length > 0) {
|
||||
folderList.innerHTML = data.folders.map(f =>
|
||||
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
|
||||
`<svg class="h-5 w-5 text-yellow-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
|
||||
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
|
||||
`</button>`
|
||||
).join('');
|
||||
|
||||
folderList.querySelectorAll('.folder-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = btn.getAttribute('data-path');
|
||||
selectedInput.value = p;
|
||||
loadFolders(p);
|
||||
});
|
||||
});
|
||||
} else {
|
||||
folderList.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
|
||||
}
|
||||
updateBreadcrumb(path);
|
||||
})
|
||||
.catch(err => {
|
||||
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateBreadcrumb(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
accumulated += '/' + part;
|
||||
html += `<span class="mx-1 text-gray-400">/</span>`;
|
||||
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated)}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
|
||||
}
|
||||
breadcrumb.innerHTML = html;
|
||||
breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = btn.getAttribute('data-path');
|
||||
selectedInput.value = p || '/';
|
||||
loadFolders(p);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Save selected folder to integration config
|
||||
saveBtn.addEventListener('click', () => {
|
||||
const folderPath = selectedInput.value.trim() || '/';
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving…';
|
||||
|
||||
// Get the current integration config, update folder_path, then PUT back
|
||||
fetch(`/api/integrations/${integrationId}`)
|
||||
.then(r => r.json())
|
||||
.then(intg => {
|
||||
const cfg = intg.config || {};
|
||||
// Update the correct folder key based on integration type
|
||||
if (cfg.source_type) {
|
||||
cfg.folder_path = folderPath;
|
||||
} else {
|
||||
cfg.folder = folderPath;
|
||||
}
|
||||
return fetch(`/api/integrations/${integrationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: cfg }),
|
||||
});
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to save folder');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
saveStatus.textContent = '✓ Folder saved! Redirecting…';
|
||||
saveStatus.className = 'mt-2 text-sm text-green-600';
|
||||
saveStatus.classList.remove('hidden');
|
||||
saveBtn.textContent = 'Saved ✓';
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 1500);
|
||||
})
|
||||
.catch(err => {
|
||||
saveStatus.textContent = 'Error: ' + err.message;
|
||||
saveStatus.className = 'mt-2 text-sm text-red-600';
|
||||
saveStatus.classList.remove('hidden');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Folder';
|
||||
});
|
||||
});
|
||||
|
||||
// Load root folders initially
|
||||
loadFolders('');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -0,0 +1,945 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Comments & Annotations - {{ file.original_filename or 'Document' }} - DocuElevate{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/js/common.js"></script>
|
||||
<style>
|
||||
.annotations-container { max-width: 1200px; margin: 0 auto; }
|
||||
|
||||
/* ── PDF viewer ── */
|
||||
.pdf-viewer-card {
|
||||
background: white; border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
margin-bottom: 1.25rem; overflow: hidden;
|
||||
}
|
||||
.dark .pdf-viewer-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||
.pdf-viewer-card-header {
|
||||
display: flex; align-items: center; gap: 0.5rem;
|
||||
padding: 1rem 1.5rem; border-bottom: 1px solid #e5e7eb;
|
||||
font-weight: 700; font-size: 0.95rem; color: #374151;
|
||||
}
|
||||
.dark .pdf-viewer-card-header { border-bottom-color: #374151; color: #d1d5db; }
|
||||
#embedpdf-viewer { width: 100%; height: 600px; }
|
||||
|
||||
/* ── header ── */
|
||||
.annotations-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.annotations-title { font-size: 1.5rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
|
||||
.annotations-subtitle { font-size: 0.85rem; color: #6b7280; margin-top: 0.25rem; }
|
||||
.dark .annotations-title { color: #f3f4f6; }
|
||||
.dark .annotations-subtitle { color: #9ca3af; }
|
||||
|
||||
/* ── error ── */
|
||||
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
|
||||
|
||||
/* ── card ── */
|
||||
.collab-card {
|
||||
background: white; border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
padding: 1.5rem; margin-bottom: 1.25rem;
|
||||
}
|
||||
.dark .collab-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||
|
||||
/* ── Comments panel ───────────────────────────────────────────────────── */
|
||||
.comments-panel, .annotations-panel {
|
||||
margin-top: 0;
|
||||
}
|
||||
.panel-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.panel-header h3 {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.comments-empty, .annotations-empty {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: #718096;
|
||||
}
|
||||
.comments-loading, .annotations-loading {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
color: #718096;
|
||||
}
|
||||
.comments-error {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
color: #991B1B;
|
||||
}
|
||||
|
||||
/* Individual comment */
|
||||
.comment-item {
|
||||
background-color: #f7fafc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid #4299e1;
|
||||
}
|
||||
.dark .comment-item {
|
||||
background-color: #2d3748;
|
||||
border-left-color: #63b3ed;
|
||||
}
|
||||
.comment-item.comment-reply {
|
||||
margin-left: 1.5rem;
|
||||
border-left-color: #a0aec0;
|
||||
background-color: #edf2f7;
|
||||
}
|
||||
.dark .comment-item.comment-reply {
|
||||
background-color: #1a202c;
|
||||
border-left-color: #4a5568;
|
||||
}
|
||||
.comment-item.comment-resolved {
|
||||
opacity: 0.75;
|
||||
border-left-color: #48bb78;
|
||||
}
|
||||
.comment-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.comment-author {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dark .comment-author {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-time {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
}
|
||||
.comment-resolved-badge {
|
||||
font-size: 0.75rem;
|
||||
color: #065F46;
|
||||
background-color: #D1FAE5;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dark .comment-resolved-badge {
|
||||
background-color: #065F46;
|
||||
color: #D1FAE5;
|
||||
}
|
||||
.comment-body {
|
||||
color: #4a5568;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dark .comment-body {
|
||||
color: #cbd5e0;
|
||||
}
|
||||
.comment-mention {
|
||||
color: #3182ce;
|
||||
font-weight: 600;
|
||||
}
|
||||
.dark .comment-mention {
|
||||
color: #63b3ed;
|
||||
}
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.comment-action-btn {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
min-height: 28px;
|
||||
}
|
||||
.comment-action-btn:hover {
|
||||
background-color: #edf2f7;
|
||||
color: #2d3748;
|
||||
}
|
||||
.dark .comment-action-btn {
|
||||
border-color: #4a5568;
|
||||
color: #a0aec0;
|
||||
}
|
||||
.dark .comment-action-btn:hover {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-action-btn--danger:hover {
|
||||
background-color: #FEE2E2;
|
||||
color: #991B1B;
|
||||
border-color: #f56565;
|
||||
}
|
||||
.dark .comment-action-btn--danger:hover {
|
||||
background-color: #742a2a;
|
||||
color: #feb2b2;
|
||||
border-color: #f56565;
|
||||
}
|
||||
.comment-replies {
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
/* Comment form */
|
||||
.comment-form-wrapper {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
.comment-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dark .comment-textarea {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #4299e1;
|
||||
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
|
||||
}
|
||||
.comment-submit-btn {
|
||||
background-color: #4299e1;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.comment-submit-btn:hover {
|
||||
background-color: #3182ce;
|
||||
}
|
||||
.comment-cancel-btn {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.dark .comment-cancel-btn {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.comment-edit-btns, .annotation-edit-btns {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
.comment-reply-form {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
}
|
||||
.dark .comment-reply-form {
|
||||
border-top-color: #4a5568;
|
||||
}
|
||||
|
||||
/* @mention dropdown */
|
||||
.mention-dropdown-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
#mention-dropdown {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 0;
|
||||
width: 280px;
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
background-color: white;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
z-index: 50;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.dark #mention-dropdown {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
}
|
||||
.mention-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
}
|
||||
.mention-item:hover, .mention-item:focus {
|
||||
background-color: #edf2f7;
|
||||
outline: none;
|
||||
}
|
||||
.dark .mention-item:hover, .dark .mention-item:focus {
|
||||
background-color: #4a5568;
|
||||
}
|
||||
.mention-user-id {
|
||||
font-weight: 600;
|
||||
color: #2d3748;
|
||||
}
|
||||
.dark .mention-user-id {
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.mention-display-name {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
}
|
||||
|
||||
/* ── Annotations panel ────────────────────────────────────────────────── */
|
||||
.annotation-item {
|
||||
background-color: #f7fafc;
|
||||
border-radius: 0.5rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 0.75rem;
|
||||
border-left: 3px solid #ecc94b;
|
||||
}
|
||||
.dark .annotation-item {
|
||||
background-color: #2d3748;
|
||||
border-left-color: #d69e2e;
|
||||
}
|
||||
.annotation-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.annotation-type {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.annotation-type--note {
|
||||
background-color: #DBEAFE;
|
||||
color: #1E3A8A;
|
||||
}
|
||||
.annotation-type--highlight {
|
||||
background-color: #FEF3C7;
|
||||
color: #92400E;
|
||||
}
|
||||
.annotation-type--underline {
|
||||
background-color: #D1FAE5;
|
||||
color: #065F46;
|
||||
}
|
||||
.annotation-type--strikethrough {
|
||||
background-color: #FEE2E2;
|
||||
color: #991B1B;
|
||||
}
|
||||
.dark .annotation-type--note {
|
||||
background-color: #1E3A8A;
|
||||
color: #DBEAFE;
|
||||
}
|
||||
.dark .annotation-type--highlight {
|
||||
background-color: #92400E;
|
||||
color: #FEF3C7;
|
||||
}
|
||||
.dark .annotation-type--underline {
|
||||
background-color: #065F46;
|
||||
color: #D1FAE5;
|
||||
}
|
||||
.dark .annotation-type--strikethrough {
|
||||
background-color: #991B1B;
|
||||
color: #FEE2E2;
|
||||
}
|
||||
.annotation-color-dot {
|
||||
display: inline-block;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.annotation-page {
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
}
|
||||
.annotation-page--link {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
text-decoration: none;
|
||||
min-height: 0;
|
||||
}
|
||||
.annotation-page--link:hover {
|
||||
color: #3182ce;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.dark .annotation-page--link:hover {
|
||||
color: #63b3ed;
|
||||
}
|
||||
.annotation-content {
|
||||
color: #4a5568;
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 0.5rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
.dark .annotation-content {
|
||||
color: #cbd5e0;
|
||||
}
|
||||
.annotation-meta {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
color: #718096;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.annotation-author {
|
||||
font-weight: 600;
|
||||
}
|
||||
.annotation-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.annotation-action-btn {
|
||||
background: none;
|
||||
border: 1px solid #e2e8f0;
|
||||
color: #718096;
|
||||
font-size: 0.75rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
min-height: 28px;
|
||||
}
|
||||
.annotation-action-btn:hover {
|
||||
background-color: #edf2f7;
|
||||
color: #2d3748;
|
||||
}
|
||||
.dark .annotation-action-btn {
|
||||
border-color: #4a5568;
|
||||
color: #a0aec0;
|
||||
}
|
||||
.dark .annotation-action-btn:hover {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.annotation-action-btn--danger:hover {
|
||||
background-color: #FEE2E2;
|
||||
color: #991B1B;
|
||||
border-color: #f56565;
|
||||
}
|
||||
.dark .annotation-action-btn--danger:hover {
|
||||
background-color: #742a2a;
|
||||
color: #feb2b2;
|
||||
}
|
||||
.annotation-textarea {
|
||||
width: 100%;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
resize: vertical;
|
||||
min-height: 60px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dark .annotation-textarea {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.annotation-textarea:focus {
|
||||
outline: none;
|
||||
border-color: #ecc94b;
|
||||
box-shadow: 0 0 0 2px rgba(236, 201, 75, 0.3);
|
||||
}
|
||||
.annotation-select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
margin-top: 0.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
.dark .annotation-select {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
.annotation-submit-btn {
|
||||
background-color: #ecc94b;
|
||||
color: #744210;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-weight: 600;
|
||||
margin-top: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.annotation-submit-btn:hover {
|
||||
background-color: #d69e2e;
|
||||
}
|
||||
.annotation-cancel-btn {
|
||||
background-color: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border: none;
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
margin-top: 0.5rem;
|
||||
margin-left: 0.5rem;
|
||||
min-height: 36px;
|
||||
}
|
||||
.dark .annotation-cancel-btn {
|
||||
background-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* Annotation form layout */
|
||||
.annotation-form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
.annotation-form-grid .form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.annotation-form-grid label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #4a5568;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.dark .annotation-form-grid label {
|
||||
color: #a0aec0;
|
||||
}
|
||||
.annotation-form-grid input,
|
||||
.annotation-form-grid select {
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 0.375rem;
|
||||
padding: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.dark .annotation-form-grid input,
|
||||
.dark .annotation-form-grid select {
|
||||
background-color: #2d3748;
|
||||
border-color: #4a5568;
|
||||
color: #e2e8f0;
|
||||
}
|
||||
|
||||
/* ── Collaboration panels grid ────────────────────────────────────────── */
|
||||
.collab-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
@media (max-width: 768px) {
|
||||
.collab-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="annotations-container">
|
||||
|
||||
{% if error %}
|
||||
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
|
||||
{% elif file %}
|
||||
|
||||
<!-- ── Back + header ── -->
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||
<a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;" aria-label="Back to File Summary">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File Summary
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="annotations-header">
|
||||
<div>
|
||||
<div class="annotations-title">
|
||||
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;margin-right:0.4rem;"></i>
|
||||
Comments & Annotations
|
||||
</div>
|
||||
<div class="annotations-subtitle">{{ file.original_filename }}</div>
|
||||
{% if multi_user_enabled %}
|
||||
<div class="annotations-subtitle" style="margin-top:0.25rem;">
|
||||
<i class="fas fa-user" aria-hidden="true" style="margin-right:0.25rem;"></i>
|
||||
{{ _("file.owner_label") }}: <strong>{{ owner_display or _("file.owner_unowned") }}</strong>
|
||||
{% if file.owner_id is none %}
|
||||
—
|
||||
<button
|
||||
id="claim-btn"
|
||||
aria-label="{{ _('file.claim_ownership') }}"
|
||||
style="background:#10b981;color:#fff;border:none;border-radius:0.375rem;padding:0.25rem 0.75rem;font-size:0.8rem;font-weight:600;cursor:pointer;"
|
||||
>
|
||||
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||
</button>
|
||||
<span id="claim-msg" style="font-size:0.8rem;margin-left:0.5rem;display:none;" role="alert"></span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── PDF Viewer (EmbedPDF) ───────────────────────────────────────────── -->
|
||||
{% if is_pdf and (processed_file_exists or original_file_exists) %}
|
||||
<div class="pdf-viewer-card">
|
||||
<div class="pdf-viewer-card-header">
|
||||
<i class="fas fa-file-pdf" aria-hidden="true" style="color:#ef4444;"></i>
|
||||
Document Viewer
|
||||
</div>
|
||||
<div id="embedpdf-viewer" aria-label="PDF document viewer"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── Comments & Annotations ──────────────────────────────────────────── -->
|
||||
<div class="collab-card">
|
||||
<div class="collab-grid">
|
||||
<!-- Comments Panel -->
|
||||
<section class="comments-panel" aria-label="{{ _('comments.heading') }}">
|
||||
<div class="panel-header">
|
||||
<h3><i class="fas fa-comments" aria-hidden="true"></i> {{ _("comments.heading") }}</h3>
|
||||
</div>
|
||||
<div id="comments-list" aria-live="polite"></div>
|
||||
|
||||
<!-- New comment form -->
|
||||
<div class="comment-form-wrapper">
|
||||
<form id="comment-form" aria-label="{{ _('comments.add_comment') }}">
|
||||
<div class="mention-dropdown-wrapper">
|
||||
<div id="mention-dropdown" class="hidden" role="listbox" aria-label="{{ _('comments.mention_users') }}"></div>
|
||||
<textarea
|
||||
id="comment-input"
|
||||
class="comment-textarea"
|
||||
placeholder="{{ _('comments.body_placeholder') }}"
|
||||
rows="3"
|
||||
aria-label="{{ _('comments.body_placeholder') }}"
|
||||
maxlength="10000"
|
||||
></textarea>
|
||||
</div>
|
||||
<button type="submit" class="comment-submit-btn">
|
||||
<i class="fas fa-paper-plane" aria-hidden="true"></i> {{ _("comments.add_comment") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Annotations Panel -->
|
||||
<section class="annotations-panel" aria-label="{{ _('annotations.heading') }}">
|
||||
<div class="panel-header">
|
||||
<h3><i class="fas fa-sticky-note" aria-hidden="true"></i> {{ _("annotations.heading") }}</h3>
|
||||
</div>
|
||||
<div id="annotations-list" aria-live="polite"></div>
|
||||
|
||||
<!-- New annotation form -->
|
||||
<div class="comment-form-wrapper">
|
||||
<form id="annotation-form" aria-label="{{ _('annotations.add') }}">
|
||||
<textarea
|
||||
id="annotation-content-input"
|
||||
class="annotation-textarea"
|
||||
placeholder="{{ _('annotations.content_placeholder') }}"
|
||||
rows="2"
|
||||
aria-label="{{ _('annotations.content_placeholder') }}"
|
||||
maxlength="5000"
|
||||
></textarea>
|
||||
<div class="annotation-form-grid">
|
||||
<div class="form-group">
|
||||
<label for="annotation-page-input">{{ _("annotations.page") }}</label>
|
||||
<input type="number" id="annotation-page-input" min="1" value="1" aria-label="{{ _('annotations.page') }}">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="annotation-type-input">{{ _("annotations.type") }}</label>
|
||||
<select id="annotation-type-input" aria-label="Annotation type">
|
||||
<option value="note">{{ _("annotations.type_note") }}</option>
|
||||
<option value="highlight">{{ _("annotations.type_highlight") }}</option>
|
||||
<option value="underline">{{ _("annotations.type_underline") }}</option>
|
||||
<option value="strikethrough">{{ _("annotations.type_strikethrough") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="annotation-color-input">{{ _("annotations.color") }}</label>
|
||||
<input type="color" id="annotation-color-input" value="#ffff00" aria-label="{{ _('annotations.color') }}">
|
||||
</div>
|
||||
</div>
|
||||
<button type="submit" class="annotation-submit-btn">
|
||||
<i class="fas fa-plus" aria-hidden="true"></i> {{ _("annotations.add") }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Sharing & Permissions ──────────────────────────────────────────── -->
|
||||
{% if current_user_role == 'owner' %}
|
||||
<div class="collab-card" id="sharing-panel" style="margin-top:1.5rem;">
|
||||
<div class="panel-header" style="padding:1rem 1.25rem;border-bottom:1px solid #e2e8f0;display:flex;align-items:center;gap:0.5rem;">
|
||||
<h3 style="font-size:1rem;font-weight:600;color:#1e293b;margin:0;">
|
||||
<i class="fas fa-share-alt" aria-hidden="true" style="color:#3b82f6;"></i>
|
||||
{{ _("sharing.heading") }}
|
||||
</h3>
|
||||
</div>
|
||||
<div style="padding:1rem 1.25rem;">
|
||||
<!-- Current shares list -->
|
||||
<div id="sharing-list" aria-live="polite" style="margin-bottom:1rem;">
|
||||
<p style="color:#64748b;font-size:0.875rem;">{{ _("sharing.loading") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Add share form -->
|
||||
<form id="sharing-form" aria-label="{{ _('sharing.add_share') }}" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-end;">
|
||||
<div style="flex:1;min-width:180px;">
|
||||
<label for="share-user-input" style="display:block;font-size:0.8rem;color:#475569;margin-bottom:0.25rem;">
|
||||
{{ _("sharing.user_id_label") }}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="share-user-input"
|
||||
placeholder="{{ _('sharing.user_id_placeholder') }}"
|
||||
style="width:100%;padding:0.5rem 0.75rem;border:1px solid #cbd5e1;border-radius:0.375rem;font-size:0.875rem;"
|
||||
/>
|
||||
</div>
|
||||
<div style="min-width:120px;">
|
||||
<label for="share-role-input" style="display:block;font-size:0.8rem;color:#475569;margin-bottom:0.25rem;">
|
||||
{{ _("sharing.role_label") }}
|
||||
</label>
|
||||
<select
|
||||
id="share-role-input"
|
||||
style="width:100%;padding:0.5rem 0.75rem;border:1px solid #cbd5e1;border-radius:0.375rem;font-size:0.875rem;background:#fff;"
|
||||
>
|
||||
<option value="viewer">{{ _("sharing.role_viewer") }}</option>
|
||||
<option value="editor">{{ _("sharing.role_editor") }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" style="padding:0.5rem 1rem;background:#3b82f6;color:#fff;border:none;border-radius:0.375rem;font-size:0.875rem;font-weight:600;cursor:pointer;min-height:2.25rem;" aria-label="{{ _('sharing.add_share') }}">
|
||||
<i class="fas fa-user-plus" aria-hidden="true"></i> {{ _("sharing.add_share") }}
|
||||
</button>
|
||||
</form>
|
||||
<p id="sharing-form-error" style="color:#ef4444;font-size:0.8rem;margin-top:0.5rem;display:none;" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- file is None without error -->
|
||||
<div class="error-box">Document not found.</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Comments & Annotations JS -->
|
||||
<script src="{{ url_for('static', path='js/comments.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', path='js/annotations.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
{% if file %}
|
||||
var fileId = {{ file.id | tojson }};
|
||||
// Detect current user from whoami endpoint
|
||||
fetch('/api/auth/whoami')
|
||||
.then(function (r) { return r.json(); })
|
||||
.then(function (data) {
|
||||
var userId = (data && (data.email || data.preferred_username)) || '';
|
||||
var commentsI18n = {
|
||||
empty: {{ _("comments.empty") | tojson }},
|
||||
add_comment: {{ _("comments.add_comment") | tojson }},
|
||||
add_reply: {{ _("comments.add_reply") | tojson }},
|
||||
edit: {{ _("comments.edit") | tojson }},
|
||||
save: {{ _("comments.save") | tojson }},
|
||||
resolve: {{ _("comments.resolve") | tojson }},
|
||||
resolved: {{ _("comments.resolved") | tojson }},
|
||||
unresolve: {{ _("comments.unresolve") | tojson }},
|
||||
delete_confirm: {{ _("comments.delete_confirm") | tojson }},
|
||||
reply_placeholder: {{ _("comments.reply_placeholder") | tojson }},
|
||||
body_placeholder: {{ _("comments.body_placeholder") | tojson }},
|
||||
mention_users: {{ _("comments.mention_users") | tojson }},
|
||||
cancel: {{ _("common.cancel") | tojson }}
|
||||
};
|
||||
var annotationsI18n = {
|
||||
empty: {{ _("annotations.empty") | tojson }},
|
||||
add: {{ _("annotations.add") | tojson }},
|
||||
save: {{ _("annotations.save") | tojson }},
|
||||
delete_confirm: {{ _("annotations.delete_confirm") | tojson }},
|
||||
page: {{ _("annotations.page") | tojson }},
|
||||
color: {{ _("annotations.color") | tojson }},
|
||||
go_to_page: {{ _("annotations.go_to_page") | tojson }},
|
||||
type_note: {{ _("annotations.type_note") | tojson }},
|
||||
type_highlight: {{ _("annotations.type_highlight") | tojson }},
|
||||
type_underline: {{ _("annotations.type_underline") | tojson }},
|
||||
type_strikethrough: {{ _("annotations.type_strikethrough") | tojson }},
|
||||
cancel: {{ _("common.cancel") | tojson }}
|
||||
};
|
||||
if (typeof initComments === 'function') {
|
||||
initComments(fileId, userId, commentsI18n);
|
||||
}
|
||||
if (typeof initAnnotations === 'function') {
|
||||
initAnnotations(fileId, userId, annotationsI18n);
|
||||
}
|
||||
{% if current_user_role == 'owner' %}
|
||||
if (typeof initSharing === 'function') {
|
||||
initSharing(fileId, {
|
||||
heading: {{ _("sharing.heading") | tojson }},
|
||||
loading: {{ _("sharing.loading") | tojson }},
|
||||
no_shares: {{ _("sharing.no_shares") | tojson }},
|
||||
add_share: {{ _("sharing.add_share") | tojson }},
|
||||
role_viewer: {{ _("sharing.role_viewer") | tojson }},
|
||||
role_editor: {{ _("sharing.role_editor") | tojson }},
|
||||
revoke: {{ _("sharing.revoke") | tojson }},
|
||||
revoke_confirm: {{ _("sharing.revoke_confirm") | tojson }},
|
||||
user_id_label: {{ _("sharing.user_id_label") | tojson }},
|
||||
user_id_placeholder: {{ _("sharing.user_id_placeholder") | tojson }},
|
||||
role_label: {{ _("sharing.role_label") | tojson }},
|
||||
error_empty_user: {{ _("sharing.error_empty_user") | tojson }},
|
||||
change_role: {{ _("sharing.change_role") | tojson }},
|
||||
});
|
||||
}
|
||||
{% endif %}
|
||||
})
|
||||
.catch(function () {
|
||||
// Auth disabled — initialise with empty user
|
||||
if (typeof initComments === 'function') initComments(fileId, '', {});
|
||||
if (typeof initAnnotations === 'function') initAnnotations(fileId, '', {});
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
<!-- Sharing JS -->
|
||||
{% if current_user_role == 'owner' %}
|
||||
<script src="{{ url_for('static', path='js/sharing.js') }}" defer></script>
|
||||
{% endif %}
|
||||
<!-- ── EmbedPDF Viewer init ── -->
|
||||
{% if file and is_pdf and (processed_file_exists or original_file_exists) %}
|
||||
<script async type="module">
|
||||
import EmbedPDF from 'https://cdn.jsdelivr.net/npm/@embedpdf/snippet@2/dist/embedpdf.js';
|
||||
|
||||
const viewerEl = document.getElementById('embedpdf-viewer');
|
||||
if (viewerEl) {
|
||||
const fileId = {{ file.id | tojson }};
|
||||
{% if processed_file_exists %}
|
||||
const pdfUrl = '/api/files/' + fileId + '/preview?version=processed';
|
||||
{% else %}
|
||||
const pdfUrl = '/api/files/' + fileId + '/preview?version=original';
|
||||
{% endif %}
|
||||
|
||||
const viewer = EmbedPDF.init({
|
||||
type: 'container',
|
||||
target: viewerEl,
|
||||
src: pdfUrl,
|
||||
});
|
||||
|
||||
if (viewer) {
|
||||
viewer.registry.then(function (registry) {
|
||||
// ── Page sync: viewer page change → update annotation form ──────────
|
||||
var scrollPlugin = registry.getPlugin('scroll');
|
||||
if (scrollPlugin) {
|
||||
var scroll = scrollPlugin.provides();
|
||||
scroll.onPageChange(function (event) {
|
||||
var pageInput = document.getElementById('annotation-page-input');
|
||||
if (pageInput) {
|
||||
pageInput.value = String(event.pageNumber);
|
||||
}
|
||||
});
|
||||
// Expose scrollToPage so the annotations panel can navigate the viewer
|
||||
window._embedpdfScrollToPage = function (pageNumber) {
|
||||
scroll.scrollToPage({ pageNumber: pageNumber });
|
||||
};
|
||||
}
|
||||
|
||||
// ── Auto-save: viewer annotation events → DocuElevate API ───────────
|
||||
var annotationPlugin = registry.getPlugin('annotation');
|
||||
if (annotationPlugin) {
|
||||
var annotation = annotationPlugin.provides();
|
||||
annotation.onAnnotationEvent(function (event) {
|
||||
if (event.type !== 'create') return;
|
||||
var ann = event.annotation;
|
||||
var pageIndex = typeof ann.pageIndex === 'number' ? ann.pageIndex
|
||||
: (typeof event.pageIndex === 'number' ? event.pageIndex : 0);
|
||||
var page = pageIndex + 1;
|
||||
var rect = ann.rect || { x: 0, y: 0, width: 0, height: 0 };
|
||||
var color = ann.strokeColor || ann.color || undefined;
|
||||
var content = (ann.contents || '').trim();
|
||||
// Map PDF annotation subtypes to DocuElevate annotation types
|
||||
var typeMap = {
|
||||
highlight: 'highlight',
|
||||
underline: 'underline',
|
||||
strikeout: 'strikethrough',
|
||||
squiggly: 'underline',
|
||||
text: 'note',
|
||||
freetext: 'note',
|
||||
ink: 'note',
|
||||
square: 'note',
|
||||
circle: 'note',
|
||||
};
|
||||
var annType = typeMap[String(ann.type).toLowerCase()] || 'note';
|
||||
if (!content) {
|
||||
var typeLabel = annType.charAt(0).toUpperCase() + annType.slice(1);
|
||||
content = typeLabel + ' \u2014 p.' + page;
|
||||
}
|
||||
var payload = {
|
||||
page: page,
|
||||
x: rect.x || 0,
|
||||
y: rect.y || 0,
|
||||
width: rect.width || 0,
|
||||
height: rect.height || 0,
|
||||
annotation_type: annType,
|
||||
content: content,
|
||||
};
|
||||
if (color) {
|
||||
payload.color = color;
|
||||
}
|
||||
fetch('/api/files/' + fileId + '/annotations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
.then(function (r) {
|
||||
if (r.ok && typeof window._reloadAnnotations === 'function') {
|
||||
window._reloadAnnotations();
|
||||
}
|
||||
})
|
||||
.catch(function (err) {
|
||||
console.error('Failed to save viewer annotation:', err);
|
||||
});
|
||||
});
|
||||
}
|
||||
}).catch(function () {});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initClaimOwnership({{ file.id | tojson }}, {
|
||||
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -1056,7 +1056,7 @@
|
||||
|
||||
html += `
|
||||
<div role="listitem">
|
||||
<a href="/files/${doc.file_id}/detail" aria-label="${title} — ${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
|
||||
<a href="/files/${doc.file_id}" aria-label="${title} — ${scorePercent}% similarity (${scoreLabel})" style="text-decoration: none; color: inherit; display: block;">
|
||||
<div style="display: flex; align-items: center; gap: 1rem; padding: 0.75rem 1rem; background-color: #f7fafc; border-radius: 0.5rem; border: 1px solid #e2e8f0; transition: border-color 0.2s; cursor: pointer;" onmouseover="this.style.borderColor='#4299e1'" onmouseout="this.style.borderColor='#e2e8f0'">
|
||||
<div style="flex-shrink: 0; width: 48px; height: 48px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: 0.875rem; color: white; background-color: ${scorePercent >= 80 ? '#48bb78' : scorePercent >= 50 ? '#ecc94b' : '#718096'};" aria-hidden="true">
|
||||
${scorePercent}%
|
||||
@@ -1091,14 +1091,23 @@
|
||||
}
|
||||
</script>
|
||||
{% endif %}
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="detail-container">
|
||||
<a href="/files" class="back-button" aria-label="Back to File List">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
||||
Back to File List
|
||||
</a>
|
||||
<div style="display:flex;align-items:center;gap:1rem;margin-bottom:1.5rem;flex-wrap:wrap;">
|
||||
<a href="/files/{{ file.id }}" class="back-button" style="margin-bottom:0;" aria-label="Back to File Summary">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i>
|
||||
Back to File Summary
|
||||
</a>
|
||||
{% if file %}
|
||||
<a href="/files/{{ file.id }}/detail" class="back-button" style="margin-bottom:0;" aria-label="View document detail for {{ file.original_filename }}">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
Document Detail
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
{% if error %}
|
||||
<div class="error-message">
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ file.document_title or file.original_filename or 'Document' }} - DocuElevate{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<script src="/static/js/common.js"></script>
|
||||
<style>
|
||||
.summary-container { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
/* ── header ── */
|
||||
.summary-header {
|
||||
display: flex; align-items: flex-start; justify-content: space-between;
|
||||
gap: 1rem; margin-bottom: 1.5rem; flex-wrap: wrap;
|
||||
}
|
||||
.summary-title { font-size: 1.75rem; font-weight: 700; color: #1f2937; line-height: 1.25; }
|
||||
.summary-subtitle { font-size: 0.9rem; color: #6b7280; margin-top: 0.25rem; font-family: monospace; }
|
||||
.dark .summary-title { color: #f3f4f6; }
|
||||
.dark .summary-subtitle { color: #9ca3af; }
|
||||
|
||||
/* ── status pill ── */
|
||||
.status-pill {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.35rem 0.85rem; border-radius: 9999px;
|
||||
font-size: 0.8rem; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
.pill-completed { background: #d1fae5; color: #065f46; }
|
||||
.pill-failed { background: #fee2e2; color: #991b1b; }
|
||||
.pill-processing { background: #dbeafe; color: #1e3a8a; }
|
||||
.pill-pending { background: #fef3c7; color: #92400e; }
|
||||
.pill-duplicate { background: #e5e7eb; color: #374151; }
|
||||
|
||||
/* ── cards ── */
|
||||
.summary-card {
|
||||
background: white; border-radius: 0.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
padding: 1.5rem; margin-bottom: 1.25rem;
|
||||
}
|
||||
.dark .summary-card { background: #1f2937; box-shadow: 0 1px 3px rgba(0,0,0,.3); }
|
||||
.summary-card-title {
|
||||
font-size: 1rem; font-weight: 700; color: #374151;
|
||||
text-transform: uppercase; letter-spacing: 0.05em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.dark .summary-card-title { color: #d1d5db; }
|
||||
|
||||
/* ── info rows ── */
|
||||
.info-row { display: flex; justify-content: space-between; padding: 0.4rem 0; border-bottom: 1px solid #f3f4f6; font-size: 0.875rem; }
|
||||
.info-row:last-child { border-bottom: none; }
|
||||
.info-key { color: #6b7280; }
|
||||
.info-val { color: #1f2937; font-family: monospace; text-align: right; word-break: break-all; max-width: 60%; }
|
||||
.dark .info-row { border-bottom-color: #374151; }
|
||||
.dark .info-key { color: #9ca3af; }
|
||||
.dark .info-val { color: #e5e7eb; }
|
||||
|
||||
/* ── nav cards ── */
|
||||
.nav-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; margin-bottom: 1.25rem; }
|
||||
@media (max-width: 768px) { .nav-grid { grid-template-columns: 1fr; } }
|
||||
.nav-card {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
background: white; border-radius: 0.5rem; box-shadow: 0 1px 3px rgba(0,0,0,.08);
|
||||
padding: 1.5rem; text-decoration: none; color: #374151;
|
||||
transition: box-shadow 0.15s, transform 0.15s; min-height: 120px;
|
||||
}
|
||||
.nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.15); transform: translateY(-2px); }
|
||||
.dark .nav-card { background: #1f2937; color: #e5e7eb; }
|
||||
.dark .nav-card:hover { box-shadow: 0 4px 12px rgba(0,0,0,.4); }
|
||||
.nav-card i { font-size: 1.5rem; margin-bottom: 0.75rem; }
|
||||
.nav-card-title { font-weight: 700; font-size: 0.95rem; margin-bottom: 0.25rem; }
|
||||
.nav-card-desc { font-size: 0.8rem; color: #6b7280; text-align: center; }
|
||||
.dark .nav-card-desc { color: #9ca3af; }
|
||||
|
||||
/* ── action buttons ── */
|
||||
.action-btn {
|
||||
display: inline-flex; align-items: center; gap: 0.4rem;
|
||||
padding: 0.5rem 1.1rem; border-radius: 0.375rem;
|
||||
font-size: 0.875rem; font-weight: 600; text-decoration: none;
|
||||
cursor: pointer; border: none; transition: filter 0.1s;
|
||||
}
|
||||
.action-btn:hover { filter: brightness(0.92); }
|
||||
.btn-primary { background: #3b82f6; color: white; }
|
||||
.btn-secondary { background: #f3f4f6; color: #374151; }
|
||||
|
||||
/* ── error ── */
|
||||
.error-box { background: #fee2e2; border: 1px solid #f87171; color: #b91c1c; padding: 1rem; border-radius: 0.375rem; margin-bottom: 1rem; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="summary-container">
|
||||
|
||||
{% if error %}
|
||||
<div class="error-box"><strong>Error:</strong> {{ error }}</div>
|
||||
{% elif file %}
|
||||
|
||||
<!-- ── Back + header ── -->
|
||||
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files
|
||||
</a>
|
||||
|
||||
<div class="summary-header">
|
||||
<div>
|
||||
<div class="summary-title">
|
||||
{% if gpt_metadata and gpt_metadata.filename %}
|
||||
{{ gpt_metadata.filename | replace('.pdf','') | replace('_',' ') }}
|
||||
{% elif file.document_title %}
|
||||
{{ file.document_title }}
|
||||
{% else %}
|
||||
{{ file.original_filename or '(untitled)' }}
|
||||
{% endif %}
|
||||
</div>
|
||||
<div class="summary-subtitle">{{ file.original_filename }}</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:0.75rem;flex-wrap:wrap;">
|
||||
<!-- Status pill -->
|
||||
{% if file.is_duplicate %}
|
||||
<span class="status-pill pill-duplicate"><i class="fas fa-copy" aria-hidden="true"></i> Duplicate</span>
|
||||
{% elif step_summary %}
|
||||
{% set main_completed = step_summary.main.success + step_summary.main.skipped %}
|
||||
{% if step_summary.main.failure > 0 or step_summary.uploads.failure > 0 %}
|
||||
<span class="status-pill pill-failed"><i class="fas fa-times-circle" aria-hidden="true"></i> Failed</span>
|
||||
{% elif step_summary.main["in_progress"] > 0 or step_summary.uploads["in_progress"] > 0 %}
|
||||
<span class="status-pill pill-processing"><i class="fas fa-circle-notch fa-spin" aria-hidden="true"></i> Processing</span>
|
||||
{% elif step_summary.total_main_steps > 0 and main_completed == step_summary.total_main_steps and step_summary.main.failure == 0 %}
|
||||
<span class="status-pill pill-completed"><i class="fas fa-check-circle" aria-hidden="true"></i> Completed</span>
|
||||
{% else %}
|
||||
<span class="status-pill pill-pending"><i class="fas fa-pause-circle" aria-hidden="true"></i> Pending</span>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Navigation cards ── -->
|
||||
<nav class="nav-grid" aria-label="File sections">
|
||||
<a href="/files/{{ file.id }}/detail" class="nav-card">
|
||||
<i class="fas fa-file-alt" aria-hidden="true" style="color:#3b82f6;"></i>
|
||||
<div class="nav-card-title">Document Detail</div>
|
||||
<div class="nav-card-desc">Metadata, preview & extracted text</div>
|
||||
</a>
|
||||
<a href="/files/{{ file.id }}/process" class="nav-card">
|
||||
<i class="fas fa-cogs" aria-hidden="true" style="color:#6366f1;"></i>
|
||||
<div class="nav-card-title">Processing</div>
|
||||
<div class="nav-card-desc">Pipeline status & processing history</div>
|
||||
</a>
|
||||
<a href="/files/{{ file.id }}/annotations" class="nav-card">
|
||||
<i class="fas fa-comments" aria-hidden="true" style="color:#10b981;"></i>
|
||||
<div class="nav-card-title">Comments & Annotations</div>
|
||||
<div class="nav-card-desc">Discussion & document annotations</div>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<!-- ── File info card ── -->
|
||||
<div class="summary-card">
|
||||
<div class="summary-card-title"><i class="fas fa-info-circle" aria-hidden="true" style="color:#6366f1;margin-right:0.4rem;"></i>File Information</div>
|
||||
<div class="info-row"><span class="info-key">File ID</span><span class="info-val">{{ file.id }}</span></div>
|
||||
<div class="info-row"><span class="info-key">Filename</span><span class="info-val">{{ file.original_filename }}</span></div>
|
||||
<div class="info-row"><span class="info-key">Size</span><span class="info-val">{{ (file.file_size / 1024) | round(1) }} KB</span></div>
|
||||
<div class="info-row"><span class="info-key">MIME Type</span><span class="info-val">{{ file.mime_type or 'unknown' }}</span></div>
|
||||
<div class="info-row"><span class="info-key">Created</span><span class="info-val">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}</span></div>
|
||||
{% if file.detected_language %}
|
||||
<div class="info-row"><span class="info-key">Language</span><span class="info-val">{{ file.detected_language }}</span></div>
|
||||
{% endif %}
|
||||
{% if pipeline_info %}
|
||||
<div class="info-row"><span class="info-key">Pipeline</span><span class="info-val">{{ pipeline_info.name }}</span></div>
|
||||
{% endif %}
|
||||
{% if file.document_title %}
|
||||
<div class="info-row"><span class="info-key">Document Title</span><span class="info-val">{{ file.document_title }}</span></div>
|
||||
{% endif %}
|
||||
{% if multi_user_enabled %}
|
||||
<div class="info-row">
|
||||
<span class="info-key">{{ _("file.owner_label") }}</span>
|
||||
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- ── Quick actions ── -->
|
||||
<div class="summary-card">
|
||||
<div class="summary-card-title"><i class="fas fa-bolt" aria-hidden="true" style="color:#f59e0b;margin-right:0.4rem;"></i>Quick Actions</div>
|
||||
<div style="display:flex;gap:0.75rem;flex-wrap:wrap;">
|
||||
{% if processed_file_exists %}
|
||||
<a href="/api/files/{{ file.id }}/download?version=processed" class="action-btn btn-primary">
|
||||
<i class="fas fa-download" aria-hidden="true"></i> Download Processed
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if original_file_exists %}
|
||||
<a href="/api/files/{{ file.id }}/download?version=original" class="action-btn btn-secondary">
|
||||
<i class="fas fa-download" aria-hidden="true"></i> Download Original
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="/files/{{ file.id }}/detail" class="action-btn btn-secondary">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i> View Detail
|
||||
</a>
|
||||
{% if multi_user_enabled and file.owner_id is none %}
|
||||
<button
|
||||
class="action-btn btn-primary"
|
||||
id="claim-btn"
|
||||
aria-label="{{ _('file.claim_ownership') }}"
|
||||
style="background:#10b981;"
|
||||
>
|
||||
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||
</div>
|
||||
|
||||
{% else %}
|
||||
<!-- file is None without error -->
|
||||
<div class="error-box">Document not found.</div>
|
||||
{% endif %}
|
||||
|
||||
</div>
|
||||
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initClaimOwnership({{ file.id | tojson }}, {
|
||||
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -159,8 +159,8 @@
|
||||
{% elif file %}
|
||||
|
||||
<!-- ── Back + header ── -->
|
||||
<a href="/files" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to Files">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to Files
|
||||
<a href="/files/{{ file.id }}" style="display:inline-flex;align-items:center;gap:0.4rem;color:#3b82f6;font-weight:600;text-decoration:none;margin-bottom:1.25rem;" aria-label="Back to File Summary">
|
||||
<i class="fas fa-arrow-left" aria-hidden="true"></i> Back to File Summary
|
||||
</a>
|
||||
|
||||
<div class="doc-header">
|
||||
@@ -195,8 +195,8 @@
|
||||
{% endif %}
|
||||
|
||||
<!-- Process detail link -->
|
||||
<a href="/files/{{ file.id }}/detail" class="action-btn btn-process">
|
||||
<i class="fas fa-cogs" aria-hidden="true"></i> Processing Details
|
||||
<a href="/files/{{ file.id }}/process" class="action-btn btn-process">
|
||||
<i class="fas fa-cogs" aria-hidden="true"></i> Processing
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -316,6 +316,12 @@
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
{% if multi_user_enabled %}
|
||||
<div class="info-row">
|
||||
<span class="info-key">{{ _("file.owner_label") }}</span>
|
||||
<span class="info-val">{{ owner_display or _("file.owner_unowned") }}</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
@@ -344,7 +350,18 @@
|
||||
<a href="/shared-links?file_id={{ file.id }}" class="action-btn btn-secondary">
|
||||
<i class="fas fa-share-alt" aria-hidden="true"></i> Share
|
||||
</a>
|
||||
{% if multi_user_enabled and file.owner_id is none %}
|
||||
<button
|
||||
class="action-btn btn-primary"
|
||||
id="claim-btn"
|
||||
aria-label="{{ _('file.claim_ownership') }}"
|
||||
style="background:#10b981;border:none;cursor:pointer;"
|
||||
>
|
||||
<i class="fas fa-user-check" aria-hidden="true"></i> {{ _("file.claim_ownership") }}
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
<p id="claim-msg" style="margin-top:0.5rem;font-size:0.875rem;display:none;" role="alert"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -439,10 +456,105 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% if file.detected_language %}
|
||||
<div style="font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;">
|
||||
<i class="fas fa-globe" aria-hidden="true" style="margin-right:0.25rem;"></i>
|
||||
Detected language: <strong>{{ file.detected_language }}</strong>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div id="ocr-text-block" style="display:none;">
|
||||
<pre class="ocr-text" id="ocr-text-content">{{ file.ocr_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Default-language translation ── -->
|
||||
{% if file.default_language_text %}
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
|
||||
<div class="doc-card-title" style="margin:0;">
|
||||
<i class="fas fa-language" aria-hidden="true" style="color:#3b82f6;margin-right:0.4rem;"></i>
|
||||
Default Language Version
|
||||
{% if file.default_language_code %}
|
||||
<span style="font-size:0.75rem;color:#6b7280;font-weight:normal;margin-left:0.5rem;">({{ file.default_language_code }})</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;">
|
||||
<button class="text-toggle" onclick="toggleDefaultLangText()" aria-expanded="false" aria-controls="default-lang-text-block">
|
||||
<i id="default-lang-toggle-icon" class="fas fa-chevron-down" aria-hidden="true"></i>
|
||||
<span id="default-lang-toggle-label">Show text</span>
|
||||
</button>
|
||||
<button class="text-toggle" onclick="copyDefaultLangText()" id="default-lang-copy-btn" style="color:#10b981;" aria-label="Copy default language text">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="default-lang-text-block" style="display:none;">
|
||||
<pre class="ocr-text" id="default-lang-text-content">{{ file.default_language_text }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
{% elif file.detected_language %}
|
||||
<!-- Translation pending or document already in default language -->
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
<div class="doc-card-title" style="margin:0;">
|
||||
<i class="fas fa-language" aria-hidden="true" style="color:#3b82f6;margin-right:0.4rem;"></i>
|
||||
Default Language Version
|
||||
</div>
|
||||
<button class="action-btn btn-secondary" onclick="loadDefaultLangText({{ file.id }})">
|
||||
<i class="fas fa-language" aria-hidden="true"></i> Load translation
|
||||
</button>
|
||||
</div>
|
||||
<div id="default-lang-load-area" style="margin-top:0.75rem;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- ── On-the-fly translation ── -->
|
||||
{% if file.ocr_text %}
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:0.75rem;">
|
||||
<div class="doc-card-title" style="margin:0;">
|
||||
<i class="fas fa-exchange-alt" aria-hidden="true" style="color:#f59e0b;margin-right:0.4rem;"></i>
|
||||
Translate to Another Language
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:0.5rem;align-items:center;flex-wrap:wrap;">
|
||||
<label for="translate-lang-select" class="sr-only">Target language</label>
|
||||
<select id="translate-lang-select" style="padding:0.4rem 0.6rem;border:1px solid #d1d5db;border-radius:0.375rem;font-size:0.85rem;min-width:160px;" aria-label="Select target language for translation">
|
||||
<option value="">Select language…</option>
|
||||
<option value="en">English</option>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="fr">Français</option>
|
||||
<option value="es">Español</option>
|
||||
<option value="it">Italiano</option>
|
||||
<option value="pt">Português</option>
|
||||
<option value="nl">Nederlands</option>
|
||||
<option value="pl">Polski</option>
|
||||
<option value="ru">Русский</option>
|
||||
<option value="zh">中文</option>
|
||||
<option value="ja">日本語</option>
|
||||
<option value="ko">한국어</option>
|
||||
<option value="ar">العربية</option>
|
||||
<option value="hi">हिन्दी</option>
|
||||
<option value="tr">Türkçe</option>
|
||||
<option value="sv">Svenska</option>
|
||||
<option value="da">Dansk</option>
|
||||
<option value="no">Norsk</option>
|
||||
<option value="fi">Suomi</option>
|
||||
<option value="cs">Čeština</option>
|
||||
<option value="ro">Română</option>
|
||||
<option value="uk">Українська</option>
|
||||
</select>
|
||||
<button class="action-btn btn-secondary" onclick="translateOnTheFly({{ file.id }})" id="translate-btn" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-language" aria-hidden="true"></i> Translate
|
||||
</button>
|
||||
<button class="text-toggle" onclick="copyTranslatedText()" id="translate-copy-btn" style="color:#10b981;display:none;" aria-label="Copy translated text">
|
||||
<i class="fas fa-copy" aria-hidden="true"></i> Copy
|
||||
</button>
|
||||
</div>
|
||||
<div id="translate-result-area" style="margin-top:0.75rem;"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% elif processed_file_exists or original_file_exists %}
|
||||
<div class="doc-card">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;">
|
||||
@@ -688,6 +800,149 @@
|
||||
});
|
||||
}
|
||||
|
||||
// ── Default-language text toggle ──
|
||||
function toggleDefaultLangText() {
|
||||
var block = document.getElementById('default-lang-text-block');
|
||||
var icon = document.getElementById('default-lang-toggle-icon');
|
||||
var label = document.getElementById('default-lang-toggle-label');
|
||||
var btn = icon ? icon.closest('button') : null;
|
||||
if (!block) return;
|
||||
if (block.style.display === 'none') {
|
||||
block.style.display = 'block';
|
||||
if (icon) icon.className = 'fas fa-chevron-up';
|
||||
if (label) label.textContent = 'Hide text';
|
||||
if (btn) btn.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
block.style.display = 'none';
|
||||
if (icon) icon.className = 'fas fa-chevron-down';
|
||||
if (label) label.textContent = 'Show text';
|
||||
if (btn) btn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
// ── Copy default-language text ──
|
||||
function copyDefaultLangText() {
|
||||
var content = document.getElementById('default-lang-text-content');
|
||||
if (!content) return;
|
||||
var text = content.textContent;
|
||||
var btn = document.getElementById('default-lang-copy-btn');
|
||||
if (!btn) return;
|
||||
var orig = btn.innerHTML;
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
}).catch(function() {
|
||||
try {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Load default-language text on demand ──
|
||||
function loadDefaultLangText(fileId) {
|
||||
var area = document.getElementById('default-lang-load-area');
|
||||
if (!area) return;
|
||||
area.innerHTML = '<div style="text-align:center;padding:1.5rem;color:#6b7280;"><i class="fas fa-spinner fa-spin fa-2x" aria-hidden="true"></i><p style="margin-top:0.5rem;">Loading translation…</p></div>';
|
||||
fetch('/files/' + fileId + '/text/default-language')
|
||||
.then(function(r) {
|
||||
if (r.status === 404) throw new Error('No translation available yet — it may still be processing');
|
||||
if (!r.ok) throw new Error('HTTP ' + r.status);
|
||||
return r.json();
|
||||
})
|
||||
.then(function(data) {
|
||||
area.innerHTML = '';
|
||||
var info = document.createElement('div');
|
||||
info.style.cssText = 'font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;';
|
||||
info.textContent = 'Translated to: ' + data.language_code + (data.detected_language ? ' (from ' + data.detected_language + ')' : '');
|
||||
area.appendChild(info);
|
||||
var pre = document.createElement('pre');
|
||||
pre.className = 'ocr-text';
|
||||
pre.textContent = data.text;
|
||||
area.appendChild(pre);
|
||||
})
|
||||
.catch(function(err) {
|
||||
area.innerHTML = '<div style="color:#92400e;padding:0.75rem;background:#fef3c7;border-radius:0.375rem;font-size:0.875rem;"><i class="fas fa-info-circle" aria-hidden="true"></i> ' + err.message + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
// ── On-the-fly translation ──
|
||||
var _translateCache = {};
|
||||
function translateOnTheFly(fileId) {
|
||||
var select = document.getElementById('translate-lang-select');
|
||||
var area = document.getElementById('translate-result-area');
|
||||
var copyBtn = document.getElementById('translate-copy-btn');
|
||||
if (!select || !area) return;
|
||||
var lang = select.value;
|
||||
if (!lang) { area.innerHTML = '<div style="color:#92400e;padding:0.75rem;background:#fef3c7;border-radius:0.375rem;font-size:0.875rem;">Please select a target language.</div>'; return; }
|
||||
|
||||
// Check cache
|
||||
if (_translateCache[lang]) {
|
||||
renderTranslation(area, _translateCache[lang], lang);
|
||||
if (copyBtn) copyBtn.style.display = '';
|
||||
return;
|
||||
}
|
||||
|
||||
area.innerHTML = '<div style="text-align:center;padding:1.5rem;color:#6b7280;" aria-live="polite"><i class="fas fa-spinner fa-spin fa-2x" aria-hidden="true"></i><p style="margin-top:0.5rem;">Translating…</p></div>';
|
||||
if (copyBtn) copyBtn.style.display = 'none';
|
||||
|
||||
fetch('/api/files/' + fileId + '/translate?lang=' + encodeURIComponent(lang))
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.json(); })
|
||||
.then(function(data) {
|
||||
_translateCache[lang] = data.text;
|
||||
renderTranslation(area, data.text, lang);
|
||||
if (copyBtn) copyBtn.style.display = '';
|
||||
})
|
||||
.catch(function(err) {
|
||||
area.innerHTML = '<div style="color:#dc2626;padding:0.75rem;background:#fee2e2;border-radius:0.375rem;font-size:0.875rem;"><i class="fas fa-exclamation-triangle" aria-hidden="true"></i> Translation failed: ' + err.message + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderTranslation(container, text, lang) {
|
||||
container.innerHTML = '';
|
||||
var info = document.createElement('div');
|
||||
info.style.cssText = 'font-size:0.75rem;color:#6b7280;margin-bottom:0.5rem;';
|
||||
info.textContent = 'Translated to: ' + lang;
|
||||
container.appendChild(info);
|
||||
var pre = document.createElement('pre');
|
||||
pre.className = 'ocr-text';
|
||||
pre.id = 'translated-text-content';
|
||||
pre.textContent = text;
|
||||
container.appendChild(pre);
|
||||
}
|
||||
|
||||
function copyTranslatedText() {
|
||||
var content = document.getElementById('translated-text-content');
|
||||
if (!content) return;
|
||||
var text = content.textContent;
|
||||
var btn = document.getElementById('translate-copy-btn');
|
||||
if (!btn) return;
|
||||
var orig = btn.innerHTML;
|
||||
navigator.clipboard.writeText(text).then(function() {
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
}).catch(function() {
|
||||
try {
|
||||
var ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;opacity:0;';
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand('copy');
|
||||
document.body.removeChild(ta);
|
||||
btn.innerHTML = '<i class="fas fa-check"></i> Copied!';
|
||||
setTimeout(function() { btn.innerHTML = orig; }, 2000);
|
||||
} catch(e) {}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Initialise on load ──
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
{% if (processed_file_exists or original_file_exists) and file %}
|
||||
@@ -699,6 +954,16 @@
|
||||
pdfInit('/api/files/{{ file.id }}/preview?version={{ pv }}');
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
initClaimOwnership({{ file.id | tojson }}, {
|
||||
confirm: {{ _("file.claim_ownership_confirm") | tojson }},
|
||||
success: {{ _("file.claim_ownership_success") | tojson }},
|
||||
failed: {{ _("file.claim_ownership_failed") | tojson }}
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
</script>
|
||||
{% if multi_user_enabled and file and file.owner_id is none %}
|
||||
<script src="{{ url_for('static', path='js/claim.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
+106
-57
@@ -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="sharepoint" {% if storage_provider == "sharepoint" %}selected{% endif %}>SharePoint</option>
|
||||
<option value="icloud" {% if storage_provider == "icloud" %}selected{% endif %}>iCloud Drive</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -716,19 +717,19 @@
|
||||
<td>{{ file.mime_type }}</td>
|
||||
<td>
|
||||
<span class="status-badge status-{{ file.processing_status }}">
|
||||
{{ file.processing_status | title }}
|
||||
{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else 'N/A' }}</td>
|
||||
<td>{{ file.created_at.strftime('%Y-%m-%d %H:%M:%S') if file.created_at else _('common.not_available') }}</td>
|
||||
<td>
|
||||
<div style="display: flex; align-items: center;">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file" aria-label="Delete {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -752,25 +753,25 @@
|
||||
tabindex="0"
|
||||
onclick="viewFileDetail({{ file.id }}, event)"
|
||||
onkeydown="if(event.key==='Enter'||event.key===' '){viewFileDetail({{ file.id }}, event);}"
|
||||
aria-label="View details for {{ file.original_filename }}"
|
||||
aria-label="{{ _('files.action_details') }} {{ file.original_filename }}"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-2">
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="font-semibold text-gray-800 truncate">{{ file.original_filename }}</p>
|
||||
<p class="text-xs text-gray-500 mt-1">ID: {{ file.id }} · {{ (file.file_size / 1024) | round(2) }} KB</p>
|
||||
<p class="text-xs text-gray-500 mt-0.5">{{ file.mime_type }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else 'N/A' }}</p>
|
||||
<p class="text-xs text-gray-400 mt-0.5">{{ file.created_at.strftime('%Y-%m-%d %H:%M') if file.created_at else _('common.not_available') }}</p>
|
||||
</div>
|
||||
<div class="flex flex-col items-end gap-2 flex-shrink-0">
|
||||
<span class="status-badge status-{{ file.processing_status }}">{{ file.processing_status | title }}</span>
|
||||
<div class="flex gap-1" role="group" aria-label="File actions" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="Quick preview" aria-label="Preview {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<span class="status-badge status-{{ file.processing_status }}">{% if file.processing_status == 'pending' %}{{ _("common.pending") }}{% elif file.processing_status == 'processing' %}{{ _("common.processing") }}{% elif file.processing_status == 'completed' %}{{ _("common.completed") }}{% elif file.processing_status == 'failed' %}{{ _("common.failed") }}{% elif file.processing_status == 'duplicate' %}{{ _("files.status_duplicate") }}{% else %}{{ file.processing_status | title }}{% endif %}</span>
|
||||
<div class="flex gap-1" role="group" aria-label="{{ _('files.file_actions_aria') }}" onclick="event.stopPropagation();" onkeydown="event.stopPropagation();">
|
||||
<button onclick="openPreviewModal({{ file.id }}, '{{ file.mime_type or '' }}', '{{ file.original_filename | e }}', event)" class="action-btn" title="{{ _('files.action_preview') }}" aria-label="{{ _('files.action_preview') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="View details" aria-label="View details for {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="viewFileDetail({{ file.id }}, event)" class="action-btn" title="{{ _('files.action_details') }}" aria-label="{{ _('files.action_details') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-info-circle" aria-hidden="true"></i>
|
||||
</button>
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="Delete file" aria-label="Delete {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<button onclick="showDeleteModal({{ file.id }}, event)" class="action-btn delete" title="{{ _('files.action_delete') }}" aria-label="{{ _('files.action_delete') }} {{ file.original_filename }}" style="min-height:44px;min-width:44px;">
|
||||
<i class="fas fa-trash" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
@@ -841,6 +842,54 @@
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// ── Translations for JavaScript ──
|
||||
const __i18n = {
|
||||
previewFallback: {{ _("files.js_preview_fallback") | tojson }},
|
||||
fullView: {{ _("files.js_full_view") | tojson }},
|
||||
download: {{ _("files.js_download") | tojson }},
|
||||
loading: {{ _("files.js_loading") | tojson }},
|
||||
failedLoadPdf: {{ _("files.js_failed_load_pdf") | tojson }},
|
||||
pdfPageInfo: {{ _("files.js_pdf_page_info") | tojson }},
|
||||
failedLoadFile: {{ _("files.js_failed_load_file") | tojson }},
|
||||
openFullViewAria: {{ _("files.js_open_full_view_aria") | tojson }},
|
||||
downloadFileAria: {{ _("files.js_download_file_aria") | tojson }},
|
||||
failedDeleteFile: {{ _("files.js_failed_delete_file") | tojson }},
|
||||
errorDeletingFile: {{ _("files.js_error_deleting_file") | tojson }},
|
||||
noSavedSearches: {{ _("files.js_no_saved_searches") | tojson }},
|
||||
loadSavedSearchesError: {{ _("files.js_load_saved_searches_error") | tojson }},
|
||||
noFiltersToSave: {{ _("files.js_no_filters_to_save") | tojson }},
|
||||
enterSearchName: {{ _("files.js_enter_search_name") | tojson }},
|
||||
deleteSavedSearchConfirm: {{ _("files.js_delete_saved_search_confirm") | tojson }},
|
||||
deleteSavedSearchAria: {{ _("files.js_delete_saved_search_aria") | tojson }},
|
||||
selectFilesDelete: {{ _("files.js_select_files_delete") | tojson }},
|
||||
confirmDeleteFiles: {{ _("files.js_confirm_delete_files") | tojson }},
|
||||
failedDeleteFiles: {{ _("files.js_failed_delete_files") | tojson }},
|
||||
errorDeletingFiles: {{ _("files.js_error_deleting_files") | tojson }},
|
||||
selectFilesReprocess: {{ _("files.js_select_files_reprocess") | tojson }},
|
||||
confirmReprocessFiles: {{ _("files.js_confirm_reprocess_files") | tojson }},
|
||||
failedReprocessFiles: {{ _("files.js_failed_reprocess_files") | tojson }},
|
||||
errorReprocessingFiles: {{ _("files.js_error_reprocessing_files") | tojson }},
|
||||
selectFilesCloudOcr: {{ _("files.js_select_files_cloud_ocr") | tojson }},
|
||||
confirmCloudOcr: {{ _("files.js_confirm_cloud_ocr") | tojson }},
|
||||
failedQueueCloudOcr: {{ _("files.js_failed_queue_cloud_ocr") | tojson }},
|
||||
errorQueuingCloudOcr: {{ _("files.js_error_queuing_cloud_ocr") | tojson }},
|
||||
selectFilesDownload: {{ _("files.js_select_files_download") | tojson }},
|
||||
failedCreateZip: {{ _("files.js_failed_create_zip") | tojson }},
|
||||
errorDownloadingFiles: {{ _("files.js_error_downloading_files") | tojson }},
|
||||
searching: {{ _("files.js_searching") | tojson }},
|
||||
searchUnavailable: {{ _("files.js_search_unavailable") | tojson }},
|
||||
searchResultsCount: {{ _("files.js_search_results_count") | tojson }},
|
||||
noResults: {{ _("files.js_no_results") | tojson }},
|
||||
untitled: {{ _("files.js_untitled") | tojson }},
|
||||
viewFile: {{ _("files.js_view_file") | tojson }},
|
||||
prevPage: {{ _("files.js_prev_page") | tojson }},
|
||||
pageInfo: {{ _("files.js_page_info") | tojson }},
|
||||
nextPage: {{ _("files.js_next_page") | tojson }},
|
||||
zoomIn: {{ _("files.js_zoom_in") | tojson }},
|
||||
zoomOut: {{ _("files.js_zoom_out") | tojson }},
|
||||
resetZoom: {{ _("files.js_reset_zoom") | tojson }},
|
||||
};
|
||||
|
||||
// Modal functionality
|
||||
const deleteModal = document.getElementById('deleteModal');
|
||||
const cancelDelete = document.getElementById('cancelDelete');
|
||||
@@ -855,7 +904,7 @@
|
||||
|
||||
function viewFileDetail(fileId, event) {
|
||||
if (event) event.stopPropagation();
|
||||
window.location.href = `/files/${fileId}/detail`;
|
||||
window.location.href = `/files/${fileId}`;
|
||||
}
|
||||
|
||||
// ── Preview modal ──
|
||||
@@ -868,7 +917,7 @@
|
||||
const title = document.getElementById('previewTitle');
|
||||
const body = document.getElementById('previewBody');
|
||||
const footer = document.getElementById('previewFooter');
|
||||
title.textContent = filename || 'Preview';
|
||||
title.textContent = filename || __i18n.previewFallback;
|
||||
body.innerHTML = '<div style="display:flex;align-items:center;justify-content:center;height:300px;color:#9ca3af;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div>';
|
||||
footer.innerHTML = '';
|
||||
_pmPdf = { doc: null, page: 1, total: 0 };
|
||||
@@ -877,8 +926,8 @@
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
footer.innerHTML =
|
||||
`<a href="/files/${fileId}" aria-label="Open full view"><i class="fas fa-expand" aria-hidden="true"></i> Full view</a>` +
|
||||
`<a href="/api/files/${fileId}/download?version=processed" aria-label="Download file"><i class="fas fa-download" aria-hidden="true"></i> Download</a>`;
|
||||
`<a href="/files/${fileId}" aria-label="${__i18n.openFullViewAria}"><i class="fas fa-expand" aria-hidden="true"></i> ${__i18n.fullView}</a>` +
|
||||
`<a href="/api/files/${fileId}/download?version=processed" aria-label="${__i18n.downloadFileAria}"><i class="fas fa-download" aria-hidden="true"></i> ${__i18n.download}</a>`;
|
||||
|
||||
// Try "processed" first; fall back to "original" on 404
|
||||
_pmLoadPreview(body, fileId, mime, filename, 'processed');
|
||||
@@ -911,7 +960,7 @@
|
||||
body.innerHTML =
|
||||
'<div class="pm-pdf-controls">' +
|
||||
'<button onclick="_pmPdfPage(-1)" id="pm-prev" aria-label="Previous page"><i class="fas fa-chevron-left" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">Loading…</span>' +
|
||||
'<span id="pm-page-info" style="font-size:0.8rem;min-width:100px;text-align:center;">' + __i18n.loading + '</span>' +
|
||||
'<button onclick="_pmPdfPage(1)" id="pm-next" aria-label="Next page"><i class="fas fa-chevron-right" aria-hidden="true"></i></button>' +
|
||||
'</div>' +
|
||||
'<div class="pm-canvas-wrap" id="pm-canvas-wrap"><div style="text-align:center;padding:3rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin" style="font-size:2rem;"></i></div></div>';
|
||||
@@ -923,7 +972,7 @@
|
||||
_pmPdfRender();
|
||||
}).catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>Failed to load PDF</p></div>';
|
||||
document.getElementById('pm-canvas-wrap').innerHTML = '<div style="text-align:center;padding:2rem;color:#f87171;"><i class="fas fa-exclamation-triangle" style="font-size:2rem;margin-bottom:0.5rem;"></i><p>' + __i18n.failedLoadPdf + '</p></div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -953,7 +1002,7 @@
|
||||
|
||||
function _pmPdfUpdateInfo() {
|
||||
var info = document.getElementById('pm-page-info');
|
||||
if (info) info.textContent = 'Page ' + _pmPdf.page + ' of ' + _pmPdf.total;
|
||||
if (info) info.textContent = __i18n.pdfPageInfo.replace('{page}', _pmPdf.page).replace('{total}', _pmPdf.total);
|
||||
var prev = document.getElementById('pm-prev');
|
||||
var next = document.getElementById('pm-next');
|
||||
if (prev) prev.disabled = _pmPdf.page <= 1;
|
||||
@@ -964,12 +1013,12 @@
|
||||
function _pmRenderImage(body, url, alt) {
|
||||
body.innerHTML =
|
||||
'<div class="pm-img-controls">' +
|
||||
'<button onclick="_pmImgZoom(1.25)" title="Zoom in" aria-label="Zoom in"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(0.8)" title="Zoom out" aria-label="Zoom out"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgReset()" title="Reset" aria-label="Reset zoom"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(1.25)" title="' + __i18n.zoomIn + '" aria-label="' + __i18n.zoomIn + '"><i class="fas fa-search-plus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgZoom(0.8)" title="' + __i18n.zoomOut + '" aria-label="' + __i18n.zoomOut + '"><i class="fas fa-search-minus" aria-hidden="true"></i></button>' +
|
||||
'<button onclick="_pmImgReset()" title="' + __i18n.resetZoom + '" aria-label="' + __i18n.resetZoom + '"><i class="fas fa-expand" aria-hidden="true"></i></button>' +
|
||||
'<span id="pm-zoom-level" style="font-size:0.8rem;color:#6b7280;">100%</span>' +
|
||||
'</div>' +
|
||||
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || 'Preview') + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
||||
'<div class="pm-img-wrap" id="pm-img-wrap"><img id="pm-preview-img" src="' + url + '" alt="' + (alt || __i18n.previewFallback) + '" draggable="false" style="max-width:100%;height:auto;"></div>';
|
||||
// Drag-pan
|
||||
var wrap = document.getElementById('pm-img-wrap');
|
||||
if (wrap) {
|
||||
@@ -1000,7 +1049,7 @@
|
||||
|
||||
// Text rendering inside preview modal
|
||||
function _pmRenderText(body, url, onError) {
|
||||
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> Loading…</div></div>';
|
||||
body.innerHTML = '<div class="pm-text-content" id="pm-text-content"><div style="text-align:center;padding:2rem;color:#94a3b8;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.loading + '</div></div>';
|
||||
fetch(url)
|
||||
.then(function(r) { if (!r.ok) throw new Error('HTTP ' + r.status); return r.text(); })
|
||||
.then(function(text) {
|
||||
@@ -1021,7 +1070,7 @@
|
||||
.catch(function() {
|
||||
if (onError) { onError(); return; }
|
||||
var c = document.getElementById('pm-text-content');
|
||||
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">Failed to load file</div>';
|
||||
if (c) c.innerHTML = '<div style="color:#f87171;padding:1rem;">' + __i18n.failedLoadFile + '</div>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1065,7 +1114,7 @@
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType && contentType.includes('application/json')) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to delete file');
|
||||
throw new Error(err.detail || __i18n.failedDeleteFile);
|
||||
});
|
||||
} else {
|
||||
// Non-JSON response (likely HTML error page)
|
||||
@@ -1084,7 +1133,7 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error deleting file: ${error.message}`);
|
||||
alert(__i18n.errorDeletingFile.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1124,14 +1173,14 @@
|
||||
const container = document.getElementById('saved-searches-list');
|
||||
if (!container) return;
|
||||
if (!searches || searches.length === 0) {
|
||||
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">No saved searches yet</span>';
|
||||
container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.noSavedSearches + '</span>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = searches.map(s => {
|
||||
const params = new URLSearchParams(s.filters);
|
||||
return `<span class="saved-search-tag" style="display: inline-flex; align-items: center; gap: 0.25rem; background: var(--bg-tertiary, #e5e7eb); padding: 0.2rem 0.5rem; border-radius: 0.25rem; font-size: 0.8rem;">
|
||||
<a href="/files?${params.toString()}" style="text-decoration: none; color: inherit;">${s.name}</a>
|
||||
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="Delete saved search ${s.name}">
|
||||
<button type="button" onclick="deleteSavedSearch(${s.id})" style="border: none; background: none; cursor: pointer; color: var(--text-muted); padding: 0; line-height: 1;" aria-label="${__i18n.deleteSavedSearchAria.replace('{name}', s.name)}">
|
||||
<i class="fas fa-times" aria-hidden="true"></i>
|
||||
</button>
|
||||
</span>`;
|
||||
@@ -1139,7 +1188,7 @@
|
||||
})
|
||||
.catch(() => {
|
||||
const container = document.getElementById('saved-searches-list');
|
||||
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">Could not load saved searches</span>';
|
||||
if (container) container.innerHTML = '<span style="color: var(--text-muted); font-size: 0.85rem;">' + __i18n.loadSavedSearchesError + '</span>';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1152,10 +1201,10 @@
|
||||
if (val) filters[key] = val;
|
||||
});
|
||||
if (Object.keys(filters).length === 0) {
|
||||
alert('No filters to save. Please apply at least one filter (search, date, status, tags, etc.) before saving.');
|
||||
alert(__i18n.noFiltersToSave);
|
||||
return;
|
||||
}
|
||||
const name = prompt('Enter a name for this saved search:');
|
||||
const name = prompt(__i18n.enterSearchName);
|
||||
if (!name || !name.trim()) return;
|
||||
fetch('/api/saved-searches', {
|
||||
method: 'POST',
|
||||
@@ -1171,7 +1220,7 @@
|
||||
}
|
||||
|
||||
function deleteSavedSearch(id) {
|
||||
if (!confirm('Delete this saved search?')) return;
|
||||
if (!confirm(__i18n.deleteSavedSearchConfirm)) return;
|
||||
fetch(`/api/saved-searches/${id}`, { method: 'DELETE' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('Failed to delete');
|
||||
@@ -1226,11 +1275,11 @@
|
||||
function bulkDelete() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to delete');
|
||||
alert(__i18n.selectFilesDelete);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to delete ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmDeleteFiles.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1244,7 +1293,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to delete files');
|
||||
throw new Error(err.detail || __i18n.failedDeleteFiles);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1255,18 +1304,18 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error deleting files: ${error.message}`);
|
||||
alert(__i18n.errorDeletingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkReprocess() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to reprocess');
|
||||
alert(__i18n.selectFilesReprocess);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to reprocess ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmReprocessFiles.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1280,7 +1329,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to reprocess files');
|
||||
throw new Error(err.detail || __i18n.failedReprocessFiles);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1297,18 +1346,18 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error reprocessing files: ${error.message}`);
|
||||
alert(__i18n.errorReprocessingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkReprocessCloudOcr() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to re-run Cloud OCR on');
|
||||
alert(__i18n.selectFilesCloudOcr);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(`Are you sure you want to re-run Cloud OCR on ${fileIds.length} file(s)?`)) {
|
||||
if (!confirm(__i18n.confirmCloudOcr.replace('{count}', fileIds.length))) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1322,7 +1371,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to queue Cloud OCR');
|
||||
throw new Error(err.detail || __i18n.failedQueueCloudOcr);
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
@@ -1339,14 +1388,14 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error queuing Cloud OCR: ${error.message}`);
|
||||
alert(__i18n.errorQueuingCloudOcr.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
function bulkDownload() {
|
||||
const fileIds = getSelectedFileIds();
|
||||
if (fileIds.length === 0) {
|
||||
alert('Please select files to download');
|
||||
alert(__i18n.selectFilesDownload);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1360,7 +1409,7 @@
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(err => {
|
||||
throw new Error(err.detail || 'Failed to create ZIP download');
|
||||
throw new Error(err.detail || __i18n.failedCreateZip);
|
||||
});
|
||||
}
|
||||
return response.blob();
|
||||
@@ -1377,7 +1426,7 @@
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert(`Error downloading files: ${error.message}`);
|
||||
alert(__i18n.errorDownloadingFiles.replace('{error}', error.message));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1471,7 +1520,7 @@
|
||||
const summary = document.getElementById('search-results-summary');
|
||||
const pagination = document.getElementById('search-results-pagination');
|
||||
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;"><i class="fas fa-spinner fa-spin"></i> Searching…</div>';
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;"><i class="fas fa-spinner fa-spin"></i> ' + __i18n.searching + '</div>';
|
||||
summary.textContent = '';
|
||||
pagination.innerHTML = '';
|
||||
panel.style.display = 'block';
|
||||
@@ -1484,7 +1533,7 @@
|
||||
})
|
||||
.then(data => renderSearchResults(data, q))
|
||||
.catch(err => {
|
||||
list.innerHTML = `<div style="padding: 1rem; color: #dc2626; font-size: 0.875rem;"><i class="fas fa-exclamation-triangle"></i> Search unavailable: ${err.message}</div>`;
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #dc2626; font-size: 0.875rem;"><i class="fas fa-exclamation-triangle"></i> ' + __i18n.searchUnavailable.replace('{error}', err.message) + '</div>';
|
||||
summary.textContent = '';
|
||||
});
|
||||
}
|
||||
@@ -1496,17 +1545,17 @@
|
||||
const pagination = document.getElementById('search-results-pagination');
|
||||
|
||||
const { results, total, page, pages } = data;
|
||||
summary.textContent = `${total} result${total !== 1 ? 's' : ''} for "${q}"`;
|
||||
summary.textContent = __i18n.searchResultsCount.replace('{total}', total).replace('{query}', q);
|
||||
|
||||
if (!results || results.length === 0) {
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;">No results found.</div>';
|
||||
list.innerHTML = '<div style="padding: 1rem; color: #6b7280; font-size: 0.875rem;">' + __i18n.noResults + '</div>';
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
list.innerHTML = results.map(hit => {
|
||||
const fmt = hit._formatted || {};
|
||||
const title = fmt.document_title || hit.document_title || hit.original_filename || '(untitled)';
|
||||
const title = fmt.document_title || hit.document_title || hit.original_filename || __i18n.untitled;
|
||||
const filename = fmt.original_filename || hit.original_filename || '';
|
||||
const snippet = fmt.ocr_text || '';
|
||||
const tags = Array.isArray(hit.tags) ? hit.tags.join(', ') : (hit.tags || '');
|
||||
@@ -1524,7 +1573,7 @@
|
||||
${snippet ? `<div style="margin-top: 0.4rem; font-size: 0.8rem; color: #374151; white-space: pre-wrap; word-break: break-word;">…${snippet}…</div>` : ''}
|
||||
</div>
|
||||
<div style="flex-shrink: 0;">
|
||||
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="View file">
|
||||
<a href="/files/${hit.file_id}" style="padding: 0.25rem 0.6rem; background: #f3f4f6; color: #374151; border-radius: 0.25rem; font-size: 0.8rem; text-decoration: none; white-space: nowrap;" title="${__i18n.viewFile}">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
</div>
|
||||
@@ -1535,11 +1584,11 @@
|
||||
if (pages > 1) {
|
||||
const btns = [];
|
||||
if (page > 1) {
|
||||
btns.push(`<button onclick="runFullTextSearch(${page - 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">« Prev</button>`);
|
||||
btns.push(`<button onclick="runFullTextSearch(${page - 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.prevPage}</button>`);
|
||||
}
|
||||
btns.push(`<span style="padding: 0.25rem 0.6rem; font-size: 0.8rem; color: #6b7280;">Page ${page} / ${pages}</span>`);
|
||||
btns.push(`<span style="padding: 0.25rem 0.6rem; font-size: 0.8rem; color: #6b7280;">${__i18n.pageInfo.replace('{page}', page).replace('{pages}', pages)}</span>`);
|
||||
if (page < pages) {
|
||||
btns.push(`<button onclick="runFullTextSearch(${page + 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">Next »</button>`);
|
||||
btns.push(`<button onclick="runFullTextSearch(${page + 1})" style="padding: 0.25rem 0.6rem; border: 1px solid #d1d5db; border-radius: 0.25rem; font-size: 0.8rem; cursor: pointer; background: white;">${__i18n.nextPage}</button>`);
|
||||
}
|
||||
pagination.innerHTML = btns.join('');
|
||||
} else {
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Forgot Password</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Forgot Username</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
@@ -167,14 +167,31 @@
|
||||
<p class="mb-4">Now enter your Client ID and Client Secret below, and we'll help you complete the OAuth flow. You can set the folder ID after authentication is complete.</p>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value }}">
|
||||
{% if user_mode and has_system_credentials %}
|
||||
<!-- System credentials toggle (user mode only) -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
|
||||
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your Google account without needing your own Google Cloud app registration.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value }}">
|
||||
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID</label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret</label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your OAuth Client Secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -430,6 +447,9 @@ GOOGLE_DRIVE_FOLDER_ID={{ folder_id|default('YOUR_FOLDER_ID', true) }}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
|
||||
const systemClientId = {{ (client_id_value or '') | tojson }};
|
||||
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
|
||||
|
||||
// Store integration_id if provided (for per-user OAuth flow)
|
||||
const integrationId = "{{ integration_id or '' }}";
|
||||
@@ -437,6 +457,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
sessionStorage.setItem('oauth_integration_id', integrationId);
|
||||
}
|
||||
|
||||
// System credentials toggle
|
||||
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
|
||||
const customCredsSection = document.getElementById('custom-creds-section');
|
||||
if (useSystemCredsCheckbox && customCredsSection) {
|
||||
useSystemCredsCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
customCredsSection.classList.add('hidden');
|
||||
} else {
|
||||
customCredsSection.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Form elements
|
||||
const clientIdInput = document.getElementById('client-id');
|
||||
const clientSecretInput = document.getElementById('client-secret');
|
||||
@@ -719,8 +752,10 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
|
||||
// Start OAuth flow button
|
||||
if (startOauthFlowBtn) {
|
||||
startOauthFlowBtn.addEventListener('click', function() {
|
||||
const clientId = clientIdInput.value.trim();
|
||||
const clientSecret = clientSecretInput.value.trim();
|
||||
// Determine which credentials to use
|
||||
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
|
||||
const clientId = useSystemCreds ? systemClientId : clientIdInput.value.trim();
|
||||
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
|
||||
const folderId = folderIdInput ? folderIdInput.value.trim() : '';
|
||||
|
||||
if (!clientId) {
|
||||
@@ -736,6 +771,9 @@ GOOGLE_DRIVE_FOLDER_ID=${folderId}
|
||||
// Save values to session storage for use after redirect
|
||||
sessionStorage.setItem('google_drive_client_id', clientId);
|
||||
sessionStorage.setItem('google_drive_client_secret', clientSecret);
|
||||
if (useSystemCreds) {
|
||||
sessionStorage.setItem('google_drive_use_system_creds', 'true');
|
||||
}
|
||||
|
||||
// In admin mode store folder_id; in user mode the config is already set
|
||||
if (!userMode && folderId) {
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"name": "Which cloud storage providers does DocuElevate support?",
|
||||
"acceptedAnswer": {
|
||||
"@type": "Answer",
|
||||
"text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx."
|
||||
"text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, SharePoint, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx."
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -188,6 +188,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start bg-white shadow rounded-lg p-5">
|
||||
<i class="fab fa-microsoft text-purple-600 text-xl mr-3 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<h3 class="font-semibold text-gray-800 text-sm">SharePoint</h3>
|
||||
<p class="text-gray-500 text-xs">Upload to SharePoint Online document libraries via Graph API.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-start bg-white shadow rounded-lg p-5">
|
||||
<i class="fab fa-aws text-yellow-600 text-xl mr-3 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
|
||||
@@ -1387,7 +1387,7 @@ function integrationsDashboard() {
|
||||
if (resp.ok && data.success) {
|
||||
this.showAlert('success', `${intg.name}: Connection OK`, data.message);
|
||||
} else {
|
||||
this.showAlert('error', `${intg.name}: Connection Failed`, data.message || 'Connection test failed.');
|
||||
this.showAlert('error', `${intg.name}: Connection Failed`, data.message || {{ _("integrations.connection_test_failed_fallback")|tojson }});
|
||||
}
|
||||
} catch (err) {
|
||||
this.showAlert('error', 'Test Failed', `Network error: ${err.message || 'Unknown error'}`);
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ _("app.name") }} - {{ _("auth.login_title") }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
@@ -130,20 +130,37 @@
|
||||
</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value }}">
|
||||
{% if user_mode and has_system_credentials %}
|
||||
<!-- System credentials toggle (user mode only) -->
|
||||
<div class="bg-green-50 border border-green-200 rounded-md p-4">
|
||||
<label class="flex items-center gap-3 cursor-pointer">
|
||||
<input type="checkbox" id="use-system-creds" checked class="rounded border-gray-300 text-green-600 focus:ring-green-500 h-5 w-5" />
|
||||
<div>
|
||||
<span class="text-sm font-medium text-green-800">Use DocuElevate's app credentials</span>
|
||||
<p class="text-xs text-green-600 mt-0.5">Recommended — authorize with your OneDrive account without needing your own Azure app registration.</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value }}">
|
||||
</div>
|
||||
<div id="custom-creds-section" {% if user_mode and has_system_credentials %}class="hidden"{% endif %}>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="client-id" class="block text-sm font-medium text-gray-700">Client ID <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="text" id="client-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client ID" value="{{ client_id_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
|
||||
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
|
||||
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
|
||||
<div>
|
||||
<label for="client-secret" class="block text-sm font-medium text-gray-700">Client Secret <span class="text-red-500" aria-hidden="true">*</span></label>
|
||||
<input type="password" id="client-secret" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="Enter your Azure AD application client secret" value="{{ client_secret_value if not (user_mode and has_system_credentials) else '' }}">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tenant-id" class="block text-sm font-medium text-gray-700">Tenant ID (Optional)</label>
|
||||
<input type="text" id="tenant-id" class="mt-1 block w-full border border-gray-300 rounded-md shadow-sm py-2 px-3 focus:outline-none focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" placeholder="common" value="{{ tenant_id }}">
|
||||
<p class="text-xs text-gray-500 mt-1">Use "common" for personal accounts or your organization's Tenant ID for corporate accounts</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if not user_mode %}
|
||||
@@ -289,6 +306,10 @@ ONEDRIVE_FOLDER_PATH={{ folder_path|default('Documents/Uploads', true) }}</code>
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const userMode = {{ 'true' if user_mode else 'false' }};
|
||||
const hasSystemCredentials = {{ 'true' if (user_mode and has_system_credentials) else 'false' }};
|
||||
const systemClientId = {{ (client_id_value or '') | tojson }};
|
||||
const systemClientSecret = {{ (client_secret_value or '') | tojson }};
|
||||
const systemTenantId = {{ (tenant_id or 'common') | tojson }};
|
||||
|
||||
// Store integration_id if provided (for per-user OAuth flow)
|
||||
const integrationId = "{{ integration_id or '' }}";
|
||||
@@ -302,6 +323,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const refreshTokenBtn = document.getElementById('refresh-token-btn');
|
||||
const tokenStatus = document.getElementById('token-status');
|
||||
const clientSecretInput = document.getElementById('client-secret');
|
||||
const useSystemCredsCheckbox = document.getElementById('use-system-creds');
|
||||
const customCredsSection = document.getElementById('custom-creds-section');
|
||||
|
||||
// Toggle custom credentials section visibility
|
||||
if (useSystemCredsCheckbox && customCredsSection) {
|
||||
useSystemCredsCheckbox.addEventListener('change', function() {
|
||||
if (this.checked) {
|
||||
customCredsSection.classList.add('hidden');
|
||||
} else {
|
||||
customCredsSection.classList.remove('hidden');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Modal elements
|
||||
const resultModal = document.getElementById('resultModal');
|
||||
@@ -351,10 +385,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Start Authentication Flow button click
|
||||
startAuthFlowBtn.addEventListener('click', function() {
|
||||
const clientId = document.getElementById('client-id').value.trim();
|
||||
const clientSecret = clientSecretInput.value.trim();
|
||||
// Determine which credentials to use
|
||||
const useSystemCreds = hasSystemCredentials && useSystemCredsCheckbox && useSystemCredsCheckbox.checked;
|
||||
const clientId = useSystemCreds ? systemClientId : document.getElementById('client-id').value.trim();
|
||||
const clientSecret = useSystemCreds ? systemClientSecret : clientSecretInput.value.trim();
|
||||
const redirectUri = window.location.origin + "/onedrive-callback";
|
||||
const tenantId = document.getElementById('tenant-id').value.trim() || 'common';
|
||||
const tenantId = useSystemCreds ? systemTenantId : (document.getElementById('tenant-id').value.trim() || 'common');
|
||||
|
||||
if (!clientId) {
|
||||
showModal('error', 'Validation Error', 'Please enter your Client ID');
|
||||
@@ -370,6 +406,9 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
sessionStorage.setItem('onedrive_client_id', clientId);
|
||||
sessionStorage.setItem('onedrive_client_secret', clientSecret);
|
||||
sessionStorage.setItem('onedrive_tenant_id', tenantId);
|
||||
if (useSystemCreds) {
|
||||
sessionStorage.setItem('onedrive_use_system_creds', 'true');
|
||||
}
|
||||
|
||||
// In admin mode also store folder path; in user mode the config is already set
|
||||
if (!userMode) {
|
||||
|
||||
@@ -59,6 +59,40 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Folder selector (shown after authorization) -->
|
||||
<div id="folder-selector" class="hidden mt-6 p-4 bg-white border border-gray-200 rounded-lg">
|
||||
<h3 class="font-medium text-lg mb-3">
|
||||
<svg class="inline-block h-5 w-5 mr-1 text-gray-500" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
|
||||
<path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" />
|
||||
<path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" />
|
||||
</svg>
|
||||
Select Folder
|
||||
</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">Browse your OneDrive to select a folder for this integration.</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div id="folder-breadcrumb" class="flex items-center text-sm text-gray-500 mb-2">
|
||||
<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="folder-list" class="border border-gray-200 rounded-md max-h-64 overflow-y-auto">
|
||||
<div class="p-4 text-center text-gray-500">
|
||||
<div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div>
|
||||
<p class="text-sm">Loading folders…</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-3 flex items-center gap-3">
|
||||
<label for="selected-folder-path" class="text-sm font-medium text-gray-700 whitespace-nowrap">Selected folder:</label>
|
||||
<input id="selected-folder-path" type="text" class="flex-1 px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 text-sm" placeholder="/" />
|
||||
<button id="save-folder-btn" type="button" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
Save Folder
|
||||
</button>
|
||||
</div>
|
||||
<p id="folder-save-status" class="mt-2 text-sm hidden" aria-live="polite"></p>
|
||||
</div>
|
||||
|
||||
<div class="mt-6 p-4 bg-gray-100 rounded-md">
|
||||
<h3 class="font-medium text-lg mb-2">Configuration for Worker Nodes</h3>
|
||||
<p class="text-sm text-gray-600 mb-3">
|
||||
@@ -177,19 +211,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
return response.json();
|
||||
}).then(() => {
|
||||
// Clean up
|
||||
// Clean up session storage
|
||||
sessionStorage.removeItem('onedrive_client_id');
|
||||
sessionStorage.removeItem('onedrive_client_secret');
|
||||
sessionStorage.removeItem('onedrive_tenant_id');
|
||||
sessionStorage.removeItem('onedrive_folder_path');
|
||||
sessionStorage.removeItem('oauth_integration_id');
|
||||
sessionStorage.removeItem('onedrive_use_system_creds');
|
||||
|
||||
// Show brief success then redirect to integrations
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>' +
|
||||
'<p class="text-sm text-gray-500 mt-2">Redirecting to Integrations...</p>';
|
||||
// Hide processing spinner, show success
|
||||
document.querySelector('.animate-spin').parentNode.classList.add('hidden');
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 2000);
|
||||
document.getElementById('processing-message').innerHTML =
|
||||
'<p class="text-green-600 font-semibold">✓ OneDrive authorized successfully!</p>';
|
||||
document.getElementById('success-container').classList.remove('hidden');
|
||||
|
||||
// Show folder browser with the access token
|
||||
initFolderBrowser(data.access_token, integrationId);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -291,6 +328,120 @@ ONEDRIVE_FOLDER_PATH=${folderPath || 'Documents/Uploads'}`;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Folder browser ────────────────────────────────────────────────
|
||||
function escapeHtml(str) {
|
||||
const div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode(str));
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function initFolderBrowser(accessToken, integrationId) {
|
||||
const folderSelector = document.getElementById('folder-selector');
|
||||
if (!folderSelector || !integrationId || !accessToken) return;
|
||||
|
||||
folderSelector.classList.remove('hidden');
|
||||
|
||||
const folderList = document.getElementById('folder-list');
|
||||
const breadcrumb = document.getElementById('folder-breadcrumb');
|
||||
const selectedInput = document.getElementById('selected-folder-path');
|
||||
const saveBtn = document.getElementById('save-folder-btn');
|
||||
const saveStatus = document.getElementById('folder-save-status');
|
||||
|
||||
function loadFolders(path) {
|
||||
folderList.innerHTML = '<div class="p-4 text-center text-gray-500"><div class="animate-spin inline-block h-5 w-5 border-2 border-gray-300 border-t-indigo-600 rounded-full mb-2" role="status" aria-label="Loading folders"></div><p class="text-sm">Loading folders…</p></div>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('access_token', accessToken);
|
||||
formData.append('path', path);
|
||||
|
||||
fetch('/api/onedrive/list-folders', { method: 'POST', body: formData })
|
||||
.then(r => r.json())
|
||||
.then(data => {
|
||||
if (data.folders && data.folders.length > 0) {
|
||||
folderList.innerHTML = data.folders.map(f =>
|
||||
`<button type="button" class="folder-item w-full text-left px-4 py-3 hover:bg-indigo-50 border-b border-gray-100 flex items-center gap-3 text-sm focus:outline-none focus:bg-indigo-50" data-path="${escapeHtml(f.path)}" aria-label="Open folder ${escapeHtml(f.name)}">` +
|
||||
`<svg class="h-5 w-5 text-blue-400 flex-shrink-0" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fill-rule="evenodd" d="M2 6a2 2 0 012-2h4l2 2h4a2 2 0 012 2v1H8a3 3 0 00-3 3v1.5a1.5 1.5 0 01-3 0V6z" clip-rule="evenodd" /><path d="M6 12a2 2 0 012-2h8a2 2 0 012 2v2a2 2 0 01-2 2H2h2a2 2 0 002-2v-2z" /></svg>` +
|
||||
`<span class="font-medium text-gray-700">${escapeHtml(f.name)}</span>` +
|
||||
`</button>`
|
||||
).join('');
|
||||
|
||||
folderList.querySelectorAll('.folder-item').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = btn.getAttribute('data-path');
|
||||
selectedInput.value = p;
|
||||
loadFolders(p.replace(/^\//, ''));
|
||||
});
|
||||
});
|
||||
} else {
|
||||
folderList.innerHTML = '<div class="p-4 text-center text-gray-400 text-sm">No subfolders found</div>';
|
||||
}
|
||||
updateBreadcrumb(path);
|
||||
})
|
||||
.catch(err => {
|
||||
folderList.innerHTML = `<div class="p-4 text-center text-red-500 text-sm">Failed to load folders: ${escapeHtml(err.message)}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
function updateBreadcrumb(path) {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
let html = '<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="" aria-label="Navigate to root folder">/ Root</button>';
|
||||
let accumulated = '';
|
||||
for (const part of parts) {
|
||||
accumulated += '/' + part;
|
||||
html += `<span class="mx-1 text-gray-400">/</span>`;
|
||||
html += `<button type="button" class="folder-nav-btn text-indigo-600 hover:text-indigo-800 font-medium" data-path="${escapeHtml(accumulated.replace(/^\//, ''))}" aria-label="Navigate to ${escapeHtml(part)}">${escapeHtml(part)}</button>`;
|
||||
}
|
||||
breadcrumb.innerHTML = html;
|
||||
breadcrumb.querySelectorAll('.folder-nav-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
const p = btn.getAttribute('data-path');
|
||||
selectedInput.value = p ? '/' + p : '/';
|
||||
loadFolders(p);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Save selected folder to integration config
|
||||
saveBtn.addEventListener('click', () => {
|
||||
const folderPath = selectedInput.value.trim() || '/';
|
||||
saveBtn.disabled = true;
|
||||
saveBtn.textContent = 'Saving…';
|
||||
|
||||
fetch(`/api/integrations/${integrationId}`)
|
||||
.then(r => r.json())
|
||||
.then(intg => {
|
||||
const cfg = intg.config || {};
|
||||
cfg.folder_path = folderPath;
|
||||
return fetch(`/api/integrations/${integrationId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ config: cfg }),
|
||||
});
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) throw new Error('Failed to save folder');
|
||||
return r.json();
|
||||
})
|
||||
.then(() => {
|
||||
saveStatus.textContent = '✓ Folder saved! Redirecting…';
|
||||
saveStatus.className = 'mt-2 text-sm text-green-600';
|
||||
saveStatus.classList.remove('hidden');
|
||||
saveBtn.textContent = 'Saved ✓';
|
||||
setTimeout(() => { window.location.href = '/integrations'; }, 1500);
|
||||
})
|
||||
.catch(err => {
|
||||
saveStatus.textContent = 'Error: ' + err.message;
|
||||
saveStatus.className = 'mt-2 text-sm text-red-600';
|
||||
saveStatus.classList.remove('hidden');
|
||||
saveBtn.disabled = false;
|
||||
saveBtn.textContent = 'Save Folder';
|
||||
});
|
||||
});
|
||||
|
||||
// Load root folders initially
|
||||
loadFolders('');
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Reset Password</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
@@ -179,7 +179,7 @@
|
||||
>
|
||||
<option value="">{{ _("profile.language_auto_detect") }}</option>
|
||||
{% for lang in supported_languages %}
|
||||
<option value="{{ lang.code }}">{{ lang.flag }} {{ lang.native }} ({{ lang.name }})</option>
|
||||
<option value="{{ lang.code }}">{{ lang.native }} ({{ lang.name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
@@ -231,6 +231,28 @@
|
||||
{{ _("profile.theme_hint") }}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<!-- Default Document Language -->
|
||||
<div>
|
||||
<label for="doc-lang-select" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
<i class="fas fa-language text-gray-400 mr-1" aria-hidden="true"></i>{{ _("profile.default_document_language_label") }}
|
||||
</label>
|
||||
<select
|
||||
id="doc-lang-select"
|
||||
x-model="form.default_document_language"
|
||||
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:bg-gray-700 dark:text-white"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<option value="">{{ _("profile.default_document_language_auto") }}</option>
|
||||
{% for lang in supported_languages %}
|
||||
<option value="{{ lang.code }}">{{ lang.native }} ({{ lang.name }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ _("profile.default_document_language_hint") }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -321,9 +343,203 @@
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<!-- ── Security & Sessions card ──────────────────────────────────────── -->
|
||||
<section
|
||||
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
|
||||
aria-labelledby="security-heading"
|
||||
x-data="sessionManager()"
|
||||
x-init="loadSessions()"
|
||||
>
|
||||
<h2 id="security-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-1">
|
||||
<i class="fas fa-shield-alt text-gray-400 mr-2" aria-hidden="true"></i>{{ _("sessions.security_heading") }}
|
||||
</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
{{ _("sessions.security_subtitle") }}
|
||||
</p>
|
||||
|
||||
<!-- Session lifetime info -->
|
||||
<div class="text-xs text-gray-400 dark:text-gray-500 mb-4" x-show="lifetimeDays > 0">
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>
|
||||
<span x-text="'{{ _("sessions.session_lifetime") }}'.replace('{days}', lifetimeDays)"></span>
|
||||
</div>
|
||||
|
||||
<!-- Active sessions list -->
|
||||
<div class="space-y-3 mb-5">
|
||||
<template x-for="session in sessions" :key="session.id">
|
||||
<div
|
||||
class="flex items-center justify-between border border-gray-200 dark:border-gray-700 rounded-lg p-3"
|
||||
:class="session.is_current ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' : ''"
|
||||
>
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<i
|
||||
:class="session.device_info && session.device_info.includes('iPhone') ? 'fas fa-mobile-alt' :
|
||||
session.device_info && session.device_info.includes('iPad') ? 'fas fa-tablet-alt' :
|
||||
session.device_info && session.device_info.includes('Android') ? 'fas fa-mobile-alt' :
|
||||
session.device_info && session.device_info.includes('App') ? 'fas fa-mobile-alt' :
|
||||
'fas fa-desktop'"
|
||||
class="text-gray-400 text-lg flex-shrink-0"
|
||||
aria-hidden="true"
|
||||
></i>
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
|
||||
<span x-text="session.device_info || 'Unknown Device'"></span>
|
||||
<span
|
||||
x-show="session.is_current"
|
||||
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
|
||||
>{{ _("sessions.current_session") }}</span>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3">
|
||||
<span x-show="session.ip_address">
|
||||
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="session.ip_address"></span>
|
||||
</span>
|
||||
<span>
|
||||
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("sessions.last_active") }}
|
||||
<span x-text="timeAgo(session.last_active_at)"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
x-show="!session.is_current"
|
||||
@click="revokeSession(session.id)"
|
||||
class="flex-shrink-0 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium px-3 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
style="min-height:44px; min-width:44px;"
|
||||
:aria-label="'{{ _("sessions.revoke") }}'"
|
||||
>
|
||||
<i class="fas fa-sign-out-alt mr-1" aria-hidden="true"></i>{{ _("sessions.revoke") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<p
|
||||
x-show="sessions.length <= 1"
|
||||
class="text-sm text-gray-500 dark:text-gray-400 italic"
|
||||
>{{ _("sessions.no_other_sessions") }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Log off everywhere + QR login row -->
|
||||
<div class="flex flex-col sm:flex-row gap-3">
|
||||
<button
|
||||
@click="revokeAllSessions()"
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
|
||||
style="min-height:44px;"
|
||||
:disabled="revoking"
|
||||
>
|
||||
<i class="fas fa-power-off mr-2" aria-hidden="true"></i>
|
||||
<span x-text="revoking ? '{{ _("profile.saving") }}' : '{{ _("sessions.log_off_everywhere") }}'"></span>
|
||||
</button>
|
||||
<a
|
||||
href="/qr-login"
|
||||
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
|
||||
{{ _("sessions.qr_login_link") }}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Status banner for session actions -->
|
||||
<div
|
||||
x-show="sessionBanner.visible"
|
||||
x-transition
|
||||
class="mt-4 rounded-lg p-3 text-sm"
|
||||
:class="sessionBanner.error
|
||||
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
|
||||
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
>
|
||||
<span x-text="sessionBanner.message"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div><!-- /container -->
|
||||
|
||||
<script>
|
||||
/* ── Session Manager Alpine component ──────────────────────────────────── */
|
||||
function sessionManager() {
|
||||
return {
|
||||
sessions: [],
|
||||
lifetimeDays: 0,
|
||||
revoking: false,
|
||||
sessionBanner: { visible: false, error: false, message: '' },
|
||||
|
||||
_csrfToken() {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
},
|
||||
|
||||
async loadSessions() {
|
||||
try {
|
||||
const res = await fetch('/api/sessions/');
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
this.sessions = data.sessions || [];
|
||||
this.lifetimeDays = data.session_lifetime_days || 30;
|
||||
}
|
||||
} catch (_e) { /* silently ignore */ }
|
||||
},
|
||||
|
||||
async revokeSession(sessionId) {
|
||||
if (!confirm({{ _("sessions.confirm_revoke_one") | tojson }})) return;
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch(`/api/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
|
||||
});
|
||||
if (res.ok || res.status === 204) {
|
||||
this.sessions = this.sessions.filter(s => s.id !== sessionId);
|
||||
this._showSessionBanner({{ _("sessions.revoked_success") | tojson }}, false);
|
||||
}
|
||||
} catch (_e) {
|
||||
this._showSessionBanner('Network error — please try again.', true);
|
||||
}
|
||||
},
|
||||
|
||||
async revokeAllSessions() {
|
||||
if (!confirm({{ _("sessions.confirm_revoke_all") | tojson }})) return;
|
||||
this.revoking = true;
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch('/api/sessions/revoke-all', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
|
||||
},
|
||||
});
|
||||
if (res.ok) {
|
||||
await this.loadSessions();
|
||||
this._showSessionBanner({{ _("sessions.revoked_all_success") | tojson }}, false);
|
||||
}
|
||||
} catch (_e) {
|
||||
this._showSessionBanner('Network error — please try again.', true);
|
||||
} finally {
|
||||
this.revoking = false;
|
||||
}
|
||||
},
|
||||
|
||||
timeAgo(dateStr) {
|
||||
if (!dateStr) return 'unknown';
|
||||
const now = new Date();
|
||||
const then = new Date(dateStr);
|
||||
const diff = Math.floor((now - then) / 1000);
|
||||
if (diff < 60) return 'just now';
|
||||
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
|
||||
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
|
||||
return Math.floor(diff / 86400) + 'd ago';
|
||||
},
|
||||
|
||||
_showSessionBanner(msg, err) {
|
||||
this.sessionBanner = { visible: true, error: err, message: msg };
|
||||
if (!err) setTimeout(() => { this.sessionBanner.visible = false; }, 4000);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/* ── Profile Settings Alpine component ─────────────────────────────────── */
|
||||
function profileSettings() {
|
||||
return {
|
||||
// ── State ──────────────────────────────────────────────────────────────
|
||||
@@ -340,6 +556,7 @@ function profileSettings() {
|
||||
contact_email: '',
|
||||
preferred_language: '',
|
||||
preferred_theme: 'system',
|
||||
default_document_language: '',
|
||||
},
|
||||
|
||||
pwForm: {
|
||||
@@ -363,6 +580,7 @@ function profileSettings() {
|
||||
this.form.contact_email = data.contact_email || '';
|
||||
this.form.preferred_language = data.preferred_language || '';
|
||||
this.form.preferred_theme = data.preferred_theme || 'system';
|
||||
this.form.default_document_language = data.default_document_language || '';
|
||||
this._initialLanguage = this.form.preferred_language;
|
||||
} catch (_e) {
|
||||
// Silently ignore — user might not be logged in (rare for this page)
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _("qr_login.page_title") }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div
|
||||
x-data="qrLoginPage()"
|
||||
x-init="generateChallenge()"
|
||||
class="container mx-auto px-4 py-8 max-w-xl"
|
||||
>
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<header class="mb-8 text-center">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center justify-center gap-2">
|
||||
<i class="fas fa-qrcode text-blue-500" aria-hidden="true"></i>
|
||||
{{ _("qr_login.heading") }}
|
||||
</h1>
|
||||
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("qr_login.subtitle") }}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<!-- ── QR Code Card ───────────────────────────────────────────────────── -->
|
||||
<section
|
||||
class="bg-white dark:bg-gray-800 shadow rounded-lg p-8 mb-6 text-center"
|
||||
aria-labelledby="qr-heading"
|
||||
>
|
||||
<!-- Pending state: show QR code -->
|
||||
<template x-if="status === 'pending'">
|
||||
<div>
|
||||
<div
|
||||
class="mx-auto mb-4 bg-white p-4 inline-block rounded-lg shadow-inner"
|
||||
id="qr-container"
|
||||
aria-label="{{ _('qr_login.description') }}"
|
||||
>
|
||||
<img
|
||||
:src="qrCodeSvg"
|
||||
width="256"
|
||||
height="256"
|
||||
alt="{{ _('qr_login.description') }}"
|
||||
id="qr-image"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
|
||||
{{ _("qr_login.description") }}
|
||||
</p>
|
||||
<div class="flex items-center justify-center gap-2 text-xs text-gray-400 dark:text-gray-500">
|
||||
<i class="fas fa-hourglass-half animate-pulse" aria-hidden="true"></i>
|
||||
<span x-text="'{{ _("qr_login.time_remaining") }}'.replace('{seconds}', countdown)"></span>
|
||||
</div>
|
||||
<p class="mt-3 text-sm text-blue-600 dark:text-blue-400">
|
||||
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
|
||||
{{ _("qr_login.pending_message") }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Claimed state: success -->
|
||||
<template x-if="status === 'claimed'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-check-circle text-green-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-lg font-semibold text-green-700 dark:text-green-400 mb-2">
|
||||
{{ _("qr_login.claimed_message") }}
|
||||
</p>
|
||||
<p x-show="deviceName" class="text-sm text-gray-500 dark:text-gray-400"
|
||||
x-text="'{{ _("qr_login.claimed_device") }}'.replace('{device_name}', deviceName)">
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Expired state -->
|
||||
<template x-if="status === 'expired'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-clock text-yellow-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-base text-gray-700 dark:text-gray-300 mb-4">
|
||||
{{ _("qr_login.expired_message") }}
|
||||
</p>
|
||||
<button
|
||||
@click="generateChallenge()"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.generate_new") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Error state -->
|
||||
<template x-if="status === 'error'">
|
||||
<div class="py-8">
|
||||
<i class="fas fa-exclamation-triangle text-red-500 text-5xl mb-4" aria-hidden="true"></i>
|
||||
<p class="text-base text-gray-700 dark:text-gray-300 mb-4" x-text="errorMsg"></p>
|
||||
<button
|
||||
@click="generateChallenge()"
|
||||
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
|
||||
style="min-height:44px;"
|
||||
>
|
||||
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.generate_new") }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</section>
|
||||
|
||||
<!-- ── How it works ───────────────────────────────────────────────────── -->
|
||||
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
|
||||
<i class="fas fa-info-circle text-gray-400 mr-2" aria-hidden="true"></i>
|
||||
{{ _("qr_login.how_it_works") }}
|
||||
</h2>
|
||||
<ol class="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li>{{ _("qr_login.step_1") }}</li>
|
||||
<li>{{ _("qr_login.step_2") }}</li>
|
||||
<li>{{ _("qr_login.step_3") }}</li>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- QR code is rendered server-side; no external QR library needed -->
|
||||
|
||||
<script>
|
||||
function qrLoginPage() {
|
||||
return {
|
||||
status: 'loading', // loading | pending | claimed | expired | error
|
||||
challengeId: null,
|
||||
challengeToken: '',
|
||||
qrPayload: '',
|
||||
qrCodeSvg: '',
|
||||
expiresAt: null,
|
||||
countdown: 0,
|
||||
deviceName: '',
|
||||
errorMsg: '',
|
||||
_pollTimer: null,
|
||||
_countdownTimer: null,
|
||||
_ttlSeconds: 0,
|
||||
_receivedAt: null,
|
||||
|
||||
_csrfToken() {
|
||||
return document.cookie
|
||||
.split('; ')
|
||||
.find(row => row.startsWith('csrf_token='))
|
||||
?.split('=')[1];
|
||||
},
|
||||
|
||||
async generateChallenge() {
|
||||
this.status = 'loading';
|
||||
this._stopTimers();
|
||||
const csrf = this._csrfToken();
|
||||
try {
|
||||
const res = await fetch('/api/qr-auth/challenge', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.status = 'error';
|
||||
this.errorMsg = 'Failed to generate QR code. Please try again.';
|
||||
return;
|
||||
}
|
||||
const data = await res.json();
|
||||
this.challengeId = data.challenge_id;
|
||||
this.challengeToken = data.challenge_token;
|
||||
this.qrPayload = data.qr_payload;
|
||||
this.qrCodeSvg = data.qr_code_svg;
|
||||
this.expiresAt = new Date(data.expires_at);
|
||||
this._ttlSeconds = data.ttl_seconds || 120;
|
||||
this._receivedAt = Date.now();
|
||||
this.status = 'pending';
|
||||
this.deviceName = '';
|
||||
|
||||
// Start polling and countdown
|
||||
this._startPolling();
|
||||
this._startCountdown();
|
||||
} catch (_e) {
|
||||
this.status = 'error';
|
||||
this.errorMsg = 'Network error — please check your connection and try again.';
|
||||
}
|
||||
},
|
||||
|
||||
_startPolling() {
|
||||
this._pollTimer = setInterval(async () => {
|
||||
if (this.status !== 'pending') { this._stopTimers(); return; }
|
||||
try {
|
||||
const res = await fetch(`/api/qr-auth/challenge/${this.challengeId}/status`);
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
if (data.status === 'claimed') {
|
||||
this.status = 'claimed';
|
||||
this.deviceName = data.device_name || '';
|
||||
this._stopTimers();
|
||||
} else if (data.status === 'expired') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
} else if (data.status === 'cancelled') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
}
|
||||
} catch (_e) { /* ignore transient errors */ }
|
||||
}, 2000);
|
||||
},
|
||||
|
||||
_startCountdown() {
|
||||
this._updateCountdown();
|
||||
this._countdownTimer = setInterval(() => {
|
||||
this._updateCountdown();
|
||||
if (this.countdown <= 0 && this.status === 'pending') {
|
||||
this.status = 'expired';
|
||||
this._stopTimers();
|
||||
}
|
||||
}, 1000);
|
||||
},
|
||||
|
||||
_updateCountdown() {
|
||||
if (!this._receivedAt) { this.countdown = 0; return; }
|
||||
const elapsed = (Date.now() - this._receivedAt) / 1000;
|
||||
const remaining = Math.max(0, Math.floor(this._ttlSeconds - elapsed));
|
||||
this.countdown = remaining;
|
||||
},
|
||||
|
||||
_stopTimers() {
|
||||
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
||||
if (this._countdownTimer) { clearInterval(this._countdownTimer); this._countdownTimer = null; }
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<title>Shared Document – DocuElevate</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<!-- Tailwind CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet" />
|
||||
<!-- Tailwind CSS v3 (compiled) -->
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<!-- Font Awesome -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>DocuElevate - Create Account</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
@@ -32,6 +32,14 @@
|
||||
error: '',
|
||||
async submit() {
|
||||
this.error = '';
|
||||
if (this.username.length < 3 || this.username.length > 64) {
|
||||
this.error = 'Username must be between 3 and 64 characters.';
|
||||
return;
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(this.username)) {
|
||||
this.error = 'Username may only contain letters, numbers, hyphens, and underscores. Dots and other special characters are not allowed.';
|
||||
return;
|
||||
}
|
||||
if (this.password !== this.password_confirm) {
|
||||
this.error = 'Passwords do not match.';
|
||||
return;
|
||||
@@ -58,7 +66,12 @@
|
||||
}
|
||||
} else {
|
||||
const data = await resp.json();
|
||||
this.error = data.detail || 'Registration failed. Please try again.';
|
||||
const detail = data.detail;
|
||||
if (Array.isArray(detail)) {
|
||||
this.error = detail.map(e => e.msg || String(e)).join(' ') || 'Registration failed. Please try again.';
|
||||
} else {
|
||||
this.error = detail || 'Registration failed. Please try again.';
|
||||
}
|
||||
}
|
||||
} catch(e) {
|
||||
this.error = 'Network error. Please try again.';
|
||||
|
||||
@@ -0,0 +1,261 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ _("system_reset.page_title") }} - DocuElevate{% endblock %}
|
||||
|
||||
{% block head_extra %}
|
||||
<style>
|
||||
.reset-card { transition: box-shadow 0.2s ease; }
|
||||
.reset-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
|
||||
.confirmation-input { font-family: 'Courier New', monospace; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8" x-data="systemResetApp()">
|
||||
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<div class="flex items-center gap-3 mb-2">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-skull-crossbones mr-2 text-red-600" aria-hidden="true"></i>
|
||||
{{ _("system_reset.heading") }}
|
||||
</h1>
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{{ _("system_reset.subtitle") }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Factory Reset on Startup banner -->
|
||||
{% if factory_reset_on_startup %}
|
||||
<div class="mb-6 rounded-lg border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-700 p-4" role="alert">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<p class="font-semibold text-yellow-800 dark:text-yellow-200">{{ _("system_reset.startup_reset_active") }}</p>
|
||||
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">{{ _("system_reset.startup_reset_desc") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Warning banner -->
|
||||
<div class="mb-8 rounded-lg border-2 border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-700 p-6" role="alert">
|
||||
<div class="flex items-start gap-3">
|
||||
<i class="fas fa-radiation text-red-600 text-2xl mt-0.5" aria-hidden="true"></i>
|
||||
<div>
|
||||
<p class="font-bold text-red-800 dark:text-red-200 text-lg">{{ _("system_reset.danger_zone") }}</p>
|
||||
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{{ _("system_reset.danger_desc") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||
|
||||
<!-- Full Reset Card -->
|
||||
<div class="reset-card rounded-xl border-2 border-red-200 dark:border-red-800 bg-white dark:bg-gray-800 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center">
|
||||
<i class="fas fa-trash-alt text-red-600" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.full_reset_title") }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.full_reset_subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.full_reset_desc") }}</p>
|
||||
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-disc">
|
||||
<li>{{ _("system_reset.full_reset_item_db") }}</li>
|
||||
<li>{{ _("system_reset.full_reset_item_files") }}</li>
|
||||
<li>{{ _("system_reset.full_reset_item_cache") }}</li>
|
||||
<li>{{ _("system_reset.full_reset_item_settings_kept") }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label for="fullResetConfirm" class="block text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
|
||||
{{ _("system_reset.type_delete") }}
|
||||
</label>
|
||||
<input id="fullResetConfirm"
|
||||
type="text"
|
||||
x-model="fullResetInput"
|
||||
class="confirmation-input w-full px-3 py-2 border-2 border-red-300 dark:border-red-700 rounded-lg
|
||||
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
|
||||
placeholder="DELETE"
|
||||
autocomplete="off"
|
||||
aria-describedby="fullResetHelp" />
|
||||
<p id="fullResetHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_delete_help") }}</p>
|
||||
|
||||
<button @click="executeFullReset()"
|
||||
:disabled="fullResetInput !== 'DELETE' || loading"
|
||||
type="button"
|
||||
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
|
||||
text-base font-bold rounded-lg text-white
|
||||
bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500
|
||||
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
|
||||
aria-label="{{ _('system_reset.full_reset_button') }}">
|
||||
<template x-if="loading && activeAction === 'full'">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
<i x-show="!(loading && activeAction === 'full')" class="fas fa-trash-alt mr-2" aria-hidden="true"></i>
|
||||
{{ _("system_reset.full_reset_button") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reset & Re-import Card -->
|
||||
<div class="reset-card rounded-xl border-2 border-orange-200 dark:border-orange-800 bg-white dark:bg-gray-800 p-6">
|
||||
<div class="flex items-center gap-3 mb-4">
|
||||
<div class="h-10 w-10 rounded-full bg-orange-100 dark:bg-orange-900 flex items-center justify-center">
|
||||
<i class="fas fa-recycle text-orange-600" aria-hidden="true"></i>
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.reimport_title") }}</h2>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.reimport_subtitle") }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3 mb-6">
|
||||
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.reimport_desc") }}</p>
|
||||
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-decimal">
|
||||
<li>{{ _("system_reset.reimport_step_1") }}</li>
|
||||
<li>{{ _("system_reset.reimport_step_2") }}</li>
|
||||
<li>{{ _("system_reset.reimport_step_3") }}</li>
|
||||
</ol>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 italic">{{ _("system_reset.reimport_note") }}</p>
|
||||
</div>
|
||||
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||
<label for="reimportConfirm" class="block text-sm font-semibold text-orange-700 dark:text-orange-400 mb-2">
|
||||
{{ _("system_reset.type_reimport") }}
|
||||
</label>
|
||||
<input id="reimportConfirm"
|
||||
type="text"
|
||||
x-model="reimportInput"
|
||||
class="confirmation-input w-full px-3 py-2 border-2 border-orange-300 dark:border-orange-700 rounded-lg
|
||||
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
|
||||
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
|
||||
placeholder="REIMPORT"
|
||||
autocomplete="off"
|
||||
aria-describedby="reimportHelp" />
|
||||
<p id="reimportHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_reimport_help") }}</p>
|
||||
|
||||
<button @click="executeReimport()"
|
||||
:disabled="reimportInput !== 'REIMPORT' || loading"
|
||||
type="button"
|
||||
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
|
||||
text-base font-bold rounded-lg text-white
|
||||
bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500
|
||||
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
|
||||
aria-label="{{ _('system_reset.reimport_button') }}">
|
||||
<template x-if="loading && activeAction === 'reimport'">
|
||||
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
|
||||
</template>
|
||||
<i x-show="!(loading && activeAction === 'reimport')" class="fas fa-recycle mr-2" aria-hidden="true"></i>
|
||||
{{ _("system_reset.reimport_button") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Result banner (shown after a reset completes) -->
|
||||
<div x-show="resultMessage" x-cloak
|
||||
class="mt-8 rounded-lg p-4"
|
||||
:class="resultSuccess ? 'bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700' :
|
||||
'bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700'"
|
||||
:role="resultSuccess ? 'status' : 'alert'" aria-live="polite">
|
||||
<div class="flex items-start gap-3">
|
||||
<i :class="resultSuccess ? 'fas fa-check-circle text-green-600' : 'fas fa-times-circle text-red-600'" aria-hidden="true"></i>
|
||||
<div>
|
||||
<p class="font-semibold" :class="resultSuccess ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'"
|
||||
x-text="resultMessage"></p>
|
||||
<pre x-show="resultDetail" x-text="resultDetail"
|
||||
class="mt-2 text-xs overflow-x-auto whitespace-pre-wrap"
|
||||
:class="resultSuccess ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function systemResetApp() {
|
||||
const __i18n = {
|
||||
successFull: {{ _("system_reset.js_success_full") | tojson }},
|
||||
successReimport: {{ _("system_reset.js_success_reimport") | tojson }},
|
||||
errorGeneric: {{ _("system_reset.js_error_generic") | tojson }},
|
||||
};
|
||||
|
||||
return {
|
||||
fullResetInput: '',
|
||||
reimportInput: '',
|
||||
loading: false,
|
||||
activeAction: null,
|
||||
resultMessage: null,
|
||||
resultDetail: null,
|
||||
resultSuccess: false,
|
||||
|
||||
async executeFullReset() {
|
||||
if (this.fullResetInput !== 'DELETE') return;
|
||||
this.loading = true;
|
||||
this.activeAction = 'full';
|
||||
this.resultMessage = null;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const resp = await fetch('/api/admin/system-reset/full', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ confirmation: 'DELETE' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
this.resultSuccess = true;
|
||||
this.resultMessage = __i18n.successFull;
|
||||
this.resultDetail = JSON.stringify(data.result, null, 2);
|
||||
} else {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = data.detail || __i18n.errorGeneric;
|
||||
}
|
||||
} catch (err) {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = __i18n.errorGeneric;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.fullResetInput = '';
|
||||
}
|
||||
},
|
||||
|
||||
async executeReimport() {
|
||||
if (this.reimportInput !== 'REIMPORT') return;
|
||||
this.loading = true;
|
||||
this.activeAction = 'reimport';
|
||||
this.resultMessage = null;
|
||||
try {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
const resp = await fetch('/api/admin/system-reset/reimport', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
|
||||
body: JSON.stringify({ confirmation: 'REIMPORT' }),
|
||||
});
|
||||
const data = await resp.json();
|
||||
if (resp.ok) {
|
||||
this.resultSuccess = true;
|
||||
this.resultMessage = __i18n.successReimport;
|
||||
this.resultDetail = JSON.stringify(data.result, null, 2);
|
||||
} else {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = data.detail || __i18n.errorGeneric;
|
||||
}
|
||||
} catch (err) {
|
||||
this.resultSuccess = false;
|
||||
this.resultMessage = __i18n.errorGeneric;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
this.reimportInput = '';
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ _("auth.verify_email_page_title") }}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/static/styles.css" />
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css"
|
||||
integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
|
||||
Reference in New Issue
Block a user