fix: restore all code deleted/truncated by d2217531 Jules SSRF commit
Commitd2217531(google-labs-jules SSRF fix) catastrophically deleted 11,500+ lines across 100+ files while fixing an unrelated IMAP issue. Restored from d2217531^ (pre-bad-commit state): Deleted files (fully restored): - app/api/{automation,classification_rules,comments,sharing}.py - app/middleware/upload_rate_limit.py - app/tasks/{automation_tasks,classify_document}.py - app/utils/{automation_hooks,classification_rules}.py - docs/AppleAppStoreCompliance.md - frontend/input.css, package.json, package-lock.json, tailwind.config.js - frontend/static/js/{annotations,claim,comments,sharing}.js - frontend/templates/{admin_connections,file_annotations,file_summary}.html - tests/{test_api_files_comprehensive,test_auth_extended,test_sharing, test_comments,test_connections,test_imap_profiles,test_api_sessions, test_automation,test_classification_rules,test_api_advanced_filters, test_api_classification_rules,test_upload_rate_limit,test_api_dropbox, test_classify_document,test_comments_ui,test_upload_to_icloud, test_api_onedrive_comprehensive,test_frontend_build,test_sentry, test_diagnostic,test_database,test_views_dropbox,test_local_auth}.py Truncated files (content restored): - app/{auth,config,main,models,celery_worker,database}.py - app/api/{__init__,api_tokens,diagnostic,dropbox,files,google_drive, integrations,local_auth,mobile,onedrive,pipelines,qr_auth, settings,url_upload}.py - app/middleware/upload_rate_limit.py - app/tasks/upload_to_nextcloud.py - app/utils/{allowed_types,settings_service,settings_sync,user_scope,webhook}.py - app/views/{base,dropbox,files,google_drive,onedrive,settings}.py - docs/{API,AuthenticationSetup,ConfigurationGuide,DatabaseConfiguration, DeploymentGuide,DropboxSetup,GoogleDriveSetup,KubernetesDeployment, MobileApp,OneDriveSetup,ProductionReadiness,SentrySetup, SocialLoginSetup,UserGuide}.md - frontend/static/{js/upload.js,styles.css} - frontend/templates/{api_tokens,base,devices,dropbox,dropbox_callback, file_view,files,google_drive,onedrive,onedrive_callback, signup}.html - frontend/translations/en.json - migrations/env.py - tests/{conftest,test_api_integrations,test_api_mobile,test_api_settings, test_api_tokens,test_audit_logs,test_duplicates,test_imap_tasks, test_setup_wizard,test_views_files_comprehensive}.py Security fixes kept from post-d2217531 commits: - app/utils/network.py: DNS SSRF fail-secure fix (06b0fced) - app/utils/file_operations.py: path traversal fix (1018ea17) - tests/test_imap_tasks.py: re-applied 4 is_private_ip mock patches Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com> Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/51133dd8-9bec-41ab-aa10-3de753634187
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,8 +21,6 @@
|
||||
<!-- 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=="
|
||||
@@ -34,6 +32,34 @@
|
||||
{% 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"
|
||||
@@ -155,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>
|
||||
@@ -429,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>
|
||||
|
||||
+144
-36
@@ -3,7 +3,7 @@
|
||||
{% 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-4xl">
|
||||
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-6xl">
|
||||
|
||||
<!-- ── Header ─────────────────────────────────────────────────────────── -->
|
||||
<header class="mb-8">
|
||||
@@ -48,55 +48,85 @@
|
||||
<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-6 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-6 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-6 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-6 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-6 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-6 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</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_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-6 py-4 whitespace-nowrap">
|
||||
<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-6 py-4 whitespace-nowrap">
|
||||
<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-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-gray-500 dark:text-gray-400">
|
||||
<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-6 py-4 whitespace-nowrap">
|
||||
<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-6 py-4 whitespace-nowrap text-right">
|
||||
<button
|
||||
x-show="token.is_active"
|
||||
type="button"
|
||||
@click="revokeToken(token)"
|
||||
:disabled="revokingToken === 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="'{{ _('devices.revoke_token') }} ' + token.name"
|
||||
>
|
||||
<i :class="revokingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-sign-out-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
{{ _("devices.revoke_token") }}
|
||||
</button>
|
||||
<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>
|
||||
@@ -182,16 +212,31 @@
|
||||
x-show="device.is_active"
|
||||
type="button"
|
||||
@click="deactivateDevice(device)"
|
||||
:disabled="deactivatingDevice === device.id"
|
||||
: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="deactivatingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
|
||||
<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>
|
||||
@@ -242,8 +287,8 @@ function devicesPage() {
|
||||
devices: [],
|
||||
loadingTokens: true,
|
||||
loadingDevices: true,
|
||||
revokingToken: null,
|
||||
deactivatingDevice: null,
|
||||
actingToken: null,
|
||||
actingDevice: null,
|
||||
tokenError: null,
|
||||
deviceError: null,
|
||||
banner: { visible: false, error: false, message: '' },
|
||||
@@ -286,7 +331,7 @@ function devicesPage() {
|
||||
|
||||
async revokeToken(token) {
|
||||
if (!confirm({{ _("devices.confirm_revoke_token") | tojson }})) return;
|
||||
this.revokingToken = token.id;
|
||||
this.actingToken = token.id;
|
||||
try {
|
||||
const res = await fetch(`/api/api-tokens/${token.id}`, {
|
||||
method: 'DELETE',
|
||||
@@ -301,19 +346,61 @@ function devicesPage() {
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.revokingToken = null;
|
||||
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.deactivatingDevice = device.id;
|
||||
this.actingDevice = device.id;
|
||||
try {
|
||||
const res = await fetch(`/api/mobile/devices/${device.id}`, {
|
||||
method: 'DELETE',
|
||||
headers: { 'X-CSRF-Token': csrfToken },
|
||||
});
|
||||
if (!res.ok && res.status !== 204) {
|
||||
if (!res.ok) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.detail || 'Failed to remove device');
|
||||
}
|
||||
@@ -322,7 +409,28 @@ function devicesPage() {
|
||||
} catch (e) {
|
||||
this._showBanner(e.message, true);
|
||||
} finally {
|
||||
this.deactivatingDevice = null;
|
||||
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;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -937,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 %}
|
||||
|
||||
@@ -904,7 +904,7 @@
|
||||
|
||||
function viewFileDetail(fileId, event) {
|
||||
if (event) event.stopPropagation();
|
||||
window.location.href = `/files/${fileId}/detail`;
|
||||
window.location.href = `/files/${fileId}`;
|
||||
}
|
||||
|
||||
// ── Preview modal ──
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 - 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.';
|
||||
|
||||
Reference in New Issue
Block a user