fix: restore all code deleted/truncated by d2217531 Jules SSRF commit

Commit d2217531 (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:
copilot-swe-agent[bot]
2026-03-23 23:52:39 +00:00
parent 11a49eb7fd
commit c7d3ec57c3
115 changed files with 23532 additions and 1043 deletions
+329
View File
@@ -0,0 +1,329 @@
/* frontend/input.css
* Tailwind CSS v3 source file.
* Edit this file (not static/styles.css) — the compiled output is
* generated by running: npm run build (inside the frontend/ directory)
*/
/* ── Tailwind layers ──────────────────────────────────────────────────────── */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* ── Custom utilities ─────────────────────────────────────────────────────── */
/* =============================================================
ACCESSIBILITY
Skip-to-content link, focus indicators, and screen-reader-only
utility class following WCAG 2.1 Level AA requirements.
============================================================= */
/* Skip-to-content link: visible only on keyboard focus */
.skip-link {
position: absolute;
left: -9999px;
top: auto;
width: 1px;
height: 1px;
overflow: hidden;
z-index: 9999;
padding: 0.75rem 1.5rem;
background-color: #1d4ed8;
color: #ffffff;
font-weight: 600;
text-decoration: none;
border-radius: 0 0 0.375rem 0;
}
.skip-link:focus {
position: fixed;
top: 0;
left: 0;
width: auto;
height: auto;
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Enhanced focus-visible indicators for keyboard navigation (WCAG 2.4.7) */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
[tabindex]:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
/* Screen-reader-only utility (visually hidden, accessible to AT) */
.sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border-width: 0;
}
body {
/* Your global overrides can go here if needed */
}
.material-symbols-light--folder-managed-outline {
display: inline-block;
width: 96px;
height: 96px;
--svg: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='%23000' d='m17.212 20.404l-.108-.885q-.57-.125-.938-.33q-.368-.204-.7-.577l-.835.334l-.539-.815l.689-.577q-.165-.531-.165-1.035t.165-1.034l-.689-.577l.539-.816l.835.335q.332-.393.7-.588q.369-.195.938-.32l.108-.885h1l.107.885q.57.125.938.32t.7.588l.835-.335l.539.816l-.689.576q.166.531.166 1.035t-.166 1.035l.689.577l-.539.815l-.834-.335q-.333.373-.701.578q-.369.205-.938.33l-.107.885zm.5-1.731q.882 0 1.518-.635q.636-.636.636-1.519t-.636-1.518t-1.518-.636t-1.519.636t-.635 1.518t.635 1.519t1.518.635M4 18V6v4.435V10zm.616 1q-.691 0-1.153-.462T3 17.384V6.616q0-.691.463-1.153T4.615 5h4.981l2 2h7.789q.69 0 1.153.463T21 8.616v2.294q-.238-.152-.479-.265q-.24-.112-.521-.21v-1.82q0-.269-.173-.442T19.385 8h-8.19l-2-2h-4.58q-.269 0-.442.173T4 6.616v10.769q0 .269.173.442t.443.173h6.748q.055.275.131.515t.186.485z'/%3E%3C/svg%3E");
background-color: currentColor;
-webkit-mask-image: var(--svg);
mask-image: var(--svg);
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-size: 100% 100%;
mask-size: 100% 100%;
}
/* Ensure pagination wraps properly on small screens */
.pagination {
flex-wrap: wrap;
gap: 0.5rem;
}
.pagination-buttons {
flex-wrap: wrap;
}
/* Ensure filter items stack on very small screens */
@media (max-width: 480px) {
.filter-group {
flex-direction: column;
}
.filter-item {
min-width: unset;
width: 100%;
}
}
/* =============================================================
DARK MODE
Activated by "dark" class on <html> element.
Toggled by the navbar button; preference stored in localStorage.
Falls back to the server-side ui_default_color_scheme setting,
then to the OS prefers-color-scheme media query.
WCAG AA contrast ratios verified for all text/background pairs.
============================================================= */
/* Tell the browser we support both colour schemes */
html { color-scheme: light; }
html.dark { color-scheme: dark; }
/* ---- Base / Body ---- */
html.dark body { background-color: #111827; color: #e5e7eb; }
html.dark .bg-gray-50 { background-color: #111827; }
html.dark .bg-white { background-color: #1f2937; }
html.dark .bg-gray-100 { background-color: #374151; }
html.dark .bg-gray-200 { background-color: #4b5563; }
/* ---- Text colours ---- */
html.dark .text-gray-900 { color: #f9fafb; }
html.dark .text-gray-800 { color: #f3f4f6; }
html.dark .text-gray-700 { color: #e5e7eb; }
html.dark .text-gray-600 { color: #d1d5db; }
html.dark .text-gray-500 { color: #9ca3af; }
html.dark .text-gray-400 { color: #9ca3af; }
html.dark .text-black { color: #f9fafb; }
/* ---- Borders ---- */
html.dark .border-gray-100 { border-color: #374151; }
html.dark .border-gray-200 { border-color: #374151; }
html.dark .border-gray-300 { border-color: #4b5563; }
html.dark .border-gray-400 { border-color: #6b7280; }
html.dark .divide-gray-200 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-gray-100 > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
html.dark .divide-y > :not([hidden]) ~ :not([hidden]) { border-color: #374151; }
/* ---- Hover states ---- */
html.dark .hover\:bg-gray-50:hover { background-color: #374151; }
html.dark .hover\:bg-gray-100:hover { background-color: #4b5563; }
html.dark .hover\:text-gray-900:hover { color: #f9fafb; }
html.dark .hover\:text-gray-700:hover { color: #e5e7eb; }
/* ---- Shadows (softened for dark mode) ---- */
html.dark .shadow,
html.dark .shadow-md,
html.dark .shadow-sm,
html.dark .shadow-lg {
box-shadow: 0 1px 3px 0 rgba(0,0,0,0.6), 0 1px 2px 0 rgba(0,0,0,0.4);
}
/* ---- Alert / info-banner backgrounds ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
html.dark .bg-green-50 { background-color: #052e16; }
html.dark .bg-red-50 { background-color: #450a0a; }
html.dark .bg-yellow-50 { background-color: #451a03; }
html.dark .bg-indigo-50 { background-color: #1e1b4b; }
html.dark .bg-orange-50 { background-color: #431407; }
/* ---- Badge / pill backgrounds ---- */
html.dark .bg-blue-100 { background-color: #1e3a5f; }
html.dark .bg-green-100 { background-color: #052e16; }
html.dark .bg-red-100 { background-color: #450a0a; }
html.dark .bg-yellow-100 { background-color: #451a03; }
html.dark .bg-indigo-100 { background-color: #431407; }
html.dark .bg-orange-100 { background-color: #431407; }
html.dark .bg-purple-100 { background-color: #2e1065; }
/* ---- Status / badge text colours ---- */
html.dark .text-blue-700 { color: #93c5fd; }
html.dark .text-blue-800 { color: #bfdbfe; }
html.dark .text-green-700 { color: #86efac; }
html.dark .text-green-800 { color: #bbf7d0; }
html.dark .text-red-700 { color: #fca5a5; }
html.dark .text-red-800 { color: #fecaca; }
html.dark .text-yellow-700 { color: #fcd34d; }
html.dark .text-yellow-800 { color: #fde68a; }
html.dark .text-indigo-700 { color: #a5b4fc; }
html.dark .text-indigo-800 { color: #c7d2fe; }
html.dark .text-orange-700 { color: #fdba74; }
html.dark .text-orange-800 { color: #fed7aa; }
html.dark .text-purple-700 { color: #d8b4fe; }
html.dark .text-purple-800 { color: #e9d5ff; }
/* ---- Dropdown / popup menus ---- */
html.dark .bg-white.rounded-md.shadow-lg { background-color: #1f2937; }
html.dark .ring-black { --tw-ring-color: rgba(0,0,0,0.5); }
/* ---- Form inputs / selects / textareas ---- */
html.dark input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select,
html.dark textarea {
background-color: #374151;
border-color: #4b5563;
color: #e5e7eb;
}
html.dark input::placeholder,
html.dark textarea::placeholder {
color: #9ca3af;
}
html.dark input:focus:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
html.dark select:focus,
html.dark textarea:focus {
border-color: #60a5fa;
outline-color: #60a5fa;
}
/* ---- Table rows ---- */
html.dark thead,
html.dark .bg-gray-50 thead { background-color: #1f2937; }
html.dark thead th { color: #9ca3af; }
html.dark tbody tr:hover { background-color: #374151; }
/* ---- Code / pre ---- */
html.dark pre,
html.dark code { background-color: #111827; color: #d1d5db; }
/* ---- Dark-mode toggle button icon colour ---- */
html.dark #darkModeToggle { color: #fbbf24; }
html.dark #darkModeToggle:hover { background-color: #374151; }
/* ---- Dark-mode skip-link ---- */
html.dark .skip-link { background-color: #2563eb; }
html.dark .skip-link:focus { outline-color: #60a5fa; }
/* ---- Dark-mode focus-visible indicators ---- */
html.dark a:focus-visible,
html.dark button:focus-visible,
html.dark input:focus-visible,
html.dark select:focus-visible,
html.dark textarea:focus-visible,
html.dark [tabindex]:focus-visible {
outline-color: #60a5fa;
}
/* ---- Scrollbar (WebKit browsers) ---- */
html.dark ::-webkit-scrollbar { width: 8px; height: 8px; }
html.dark ::-webkit-scrollbar-track { background: #1f2937; }
html.dark ::-webkit-scrollbar-thumb { background: #4b5563; border-radius: 4px; }
html.dark ::-webkit-scrollbar-thumb:hover { background: #6b7280; }
/* ---- Settings page: sidebar active state (dark) ---- */
html.dark .bg-blue-50 { background-color: #1e3a5f; }
/* =============================================================
DOC-TOGGLE cross-browser toggle switch
Implemented with custom CSS pseudo-elements so the appearance
is consistent across all browsers regardless of Tailwind version.
Usage:
<label class="doc-toggle">
<input type="checkbox" class="sr-only" onchange="...">
<span class="doc-toggle-track" aria-hidden="true"></span>
<span class="ml-3 ...">Label text</span>
</label>
============================================================= */
.doc-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
}
.doc-toggle-track {
position: relative;
display: inline-block;
width: 44px;
min-width: 44px;
height: 24px;
background-color: #e5e7eb; /* gray-200 */
border-radius: 9999px;
transition: background-color 0.2s ease-in-out;
flex-shrink: 0;
}
.doc-toggle-track::after {
content: '';
position: absolute;
top: 2px;
left: 2px;
width: 20px;
height: 20px;
background-color: #ffffff;
border: 1px solid #d1d5db; /* gray-300 */
border-radius: 9999px;
transition: transform 0.2s ease-in-out, border-color 0.2s ease-in-out;
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
.doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
transform: translateX(20px);
border-color: #ffffff;
}
.doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #ffffff, 0 0 0 4px #6366f1; /* ring-2 ring-indigo-500 with offset */
}
/* Dark mode overrides */
html.dark .doc-toggle-track {
background-color: #374151; /* gray-700 */
}
html.dark .doc-toggle-track::after {
background-color: #ffffff;
border-color: #4b5563; /* gray-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track {
background-color: #4f46e5; /* indigo-600 */
}
html.dark .doc-toggle input[type="checkbox"]:checked + .doc-toggle-track::after {
border-color: #ffffff;
}
html.dark .doc-toggle input[type="checkbox"]:focus-visible + .doc-toggle-track {
box-shadow: 0 0 0 2px #111827, 0 0 0 4px #6366f1; /* dark background offset */
}
+1017
View File
File diff suppressed because it is too large Load Diff
+12
View File
@@ -0,0 +1,12 @@
{
"name": "docuelevate-frontend",
"version": "1.0.0",
"description": "Frontend asset compilation for DocuElevate",
"scripts": {
"build": "tailwindcss -i input.css -o static/styles.css --minify",
"watch": "tailwindcss -i input.css -o static/styles.css --watch"
},
"devDependencies": {
"tailwindcss": "^3.4.0"
}
}
+307
View File
@@ -0,0 +1,307 @@
// frontend/static/js/annotations.js
// Annotations panel — CRUD for PDF page annotations
(function () {
'use strict';
var _fileId = null;
var _currentUserId = null;
var _i18n = {};
// -------------------------------------------------------------------------
// Initialisation
// -------------------------------------------------------------------------
/**
* Bootstrap the annotations panel.
* @param {number} fileId
* @param {string} currentUserId
* @param {object} i18n
*/
function initAnnotations(fileId, currentUserId, i18n) {
_fileId = fileId;
_currentUserId = currentUserId;
_i18n = i18n || {};
_loadAnnotations();
// Expose reload function so the EmbedPDF viewer init script can refresh the
// list after auto-saving an annotation created inside the viewer.
window._reloadAnnotations = _loadAnnotations;
var form = document.getElementById('annotation-form');
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
_createAnnotation();
});
}
}
// -------------------------------------------------------------------------
// Data fetching
// -------------------------------------------------------------------------
function _loadAnnotations() {
var container = document.getElementById('annotations-list');
if (!container) return;
container.innerHTML = '<div class="annotations-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
fetch('/api/files/' + _fileId + '/annotations')
.then(function (r) { return r.json(); })
.then(function (data) {
_renderAnnotations(data.annotations || [], container);
})
.catch(function () {
container.innerHTML = '<p class="annotations-empty">' + (_i18n.empty || 'No annotations yet') + '</p>';
});
}
// -------------------------------------------------------------------------
// Rendering
// -------------------------------------------------------------------------
function _renderAnnotations(annotations, container) {
container.innerHTML = '';
if (!annotations.length) {
container.innerHTML = '<p class="annotations-empty"><i class="fas fa-sticky-note" aria-hidden="true"></i> ' +
(_i18n.empty || 'No annotations yet') + '</p>';
return;
}
for (var i = 0; i < annotations.length; i++) {
container.appendChild(_buildAnnotationNode(annotations[i]));
}
}
function _buildAnnotationNode(ann) {
var div = document.createElement('div');
div.className = 'annotation-item';
div.setAttribute('data-annotation-id', ann.id);
// Type badge + color indicator
var header = document.createElement('div');
header.className = 'annotation-header';
var typeBadge = document.createElement('span');
typeBadge.className = 'annotation-type annotation-type--' + ann.annotation_type;
typeBadge.textContent = _i18n['type_' + ann.annotation_type] || ann.annotation_type;
var pageInfo = document.createElement('button');
pageInfo.type = 'button';
pageInfo.className = 'annotation-page annotation-page--link';
pageInfo.setAttribute('aria-label', (_i18n.go_to_page || 'Go to page') + ' ' + ann.page);
pageInfo.title = (_i18n.go_to_page || 'Go to page') + ' ' + ann.page;
pageInfo.innerHTML = '<i class="fas fa-file-alt" aria-hidden="true"></i> ' +
(_i18n.page || 'Page') + ' ' + ann.page;
pageInfo.addEventListener('click', function () {
if (typeof window._embedpdfScrollToPage === 'function') {
window._embedpdfScrollToPage(ann.page);
}
});
header.appendChild(typeBadge);
if (ann.color) {
var colorDot = document.createElement('span');
colorDot.className = 'annotation-color-dot';
colorDot.style.backgroundColor = ann.color;
colorDot.setAttribute('aria-label', (_i18n.color || 'Color') + ': ' + ann.color);
header.appendChild(colorDot);
}
header.appendChild(pageInfo);
div.appendChild(header);
// Content
var content = document.createElement('div');
content.className = 'annotation-content';
content.id = 'annotation-content-' + ann.id;
content.textContent = ann.content;
div.appendChild(content);
// Meta
var meta = document.createElement('div');
meta.className = 'annotation-meta';
var author = document.createElement('span');
author.className = 'annotation-author';
author.textContent = ann.user_id;
var time = document.createElement('time');
time.className = 'annotation-time';
time.setAttribute('datetime', ann.created_at);
time.textContent = _formatDate(ann.created_at);
meta.appendChild(author);
meta.appendChild(time);
div.appendChild(meta);
// Actions (author only)
if (ann.user_id === _currentUserId) {
var actions = document.createElement('div');
actions.className = 'annotation-actions';
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'annotation-action-btn';
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i>';
editBtn.setAttribute('aria-label', 'Edit annotation');
editBtn.addEventListener('click', function () { _showEditForm(ann); });
actions.appendChild(editBtn);
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'annotation-action-btn annotation-action-btn--danger';
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
deleteBtn.setAttribute('aria-label', 'Delete annotation');
deleteBtn.addEventListener('click', function () { _deleteAnnotation(ann.id); });
actions.appendChild(deleteBtn);
div.appendChild(actions);
}
return div;
}
function _formatDate(iso) {
if (!iso) return '';
try {
var d = new Date(iso);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch (_e) {
return iso;
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
function _createAnnotation() {
var content = document.getElementById('annotation-content-input');
var page = document.getElementById('annotation-page-input');
var type = document.getElementById('annotation-type-input');
var color = document.getElementById('annotation-color-input');
if (!content || !content.value.trim()) return;
var payload = {
content: content.value.trim(),
page: parseInt(page ? page.value : '1', 10) || 1,
annotation_type: type ? type.value : 'note',
color: color ? color.value : null,
x: 0,
y: 0,
width: 0,
height: 0,
};
fetch('/api/files/' + _fileId + '/annotations', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
return r.json();
})
.then(function () {
content.value = '';
if (page) page.value = '1';
_loadAnnotations();
})
.catch(function () {});
}
function _deleteAnnotation(annotationId) {
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this annotation?')) return;
fetch('/api/files/' + _fileId + '/annotations/' + annotationId, {
method: 'DELETE',
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadAnnotations();
})
.catch(function () {});
}
function _showEditForm(ann) {
var contentDiv = document.getElementById('annotation-content-' + ann.id);
if (!contentDiv) return;
if (contentDiv.querySelector('.annotation-edit-form')) return;
var originalText = contentDiv.textContent;
contentDiv.textContent = '';
var form = document.createElement('div');
form.className = 'annotation-edit-form';
var textarea = document.createElement('textarea');
textarea.className = 'annotation-textarea';
textarea.value = ann.content;
textarea.rows = 3;
textarea.setAttribute('aria-label', 'Edit annotation');
var typeSelect = document.createElement('select');
typeSelect.className = 'annotation-select';
typeSelect.setAttribute('aria-label', 'Annotation type');
var types = ['note', 'highlight', 'underline', 'strikethrough'];
for (var i = 0; i < types.length; i++) {
var opt = document.createElement('option');
opt.value = types[i];
opt.textContent = _i18n['type_' + types[i]] || types[i];
if (types[i] === ann.annotation_type) opt.selected = true;
typeSelect.appendChild(opt);
}
var btns = document.createElement('div');
btns.className = 'annotation-edit-btns';
var saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'annotation-submit-btn';
saveBtn.textContent = _i18n.save || 'Save';
saveBtn.addEventListener('click', function () {
var newContent = textarea.value.trim();
if (!newContent) return;
fetch('/api/files/' + _fileId + '/annotations/' + ann.id, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
content: newContent,
annotation_type: typeSelect.value,
page: ann.page,
x: ann.x,
y: ann.y,
}),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadAnnotations();
})
.catch(function () {
contentDiv.textContent = originalText;
});
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'annotation-cancel-btn';
cancelBtn.textContent = _i18n.cancel || 'Cancel';
cancelBtn.addEventListener('click', function () {
contentDiv.textContent = originalText;
});
btns.appendChild(saveBtn);
btns.appendChild(cancelBtn);
form.appendChild(textarea);
form.appendChild(typeSelect);
form.appendChild(btns);
contentDiv.appendChild(form);
textarea.focus();
}
// Expose
window.initAnnotations = initAnnotations;
})();
+44
View File
@@ -0,0 +1,44 @@
/**
* claim.js — Claim-ownership UI helper for unowned documents.
*
* Usage: call initClaimOwnership(fileId, i18n) after DOMContentLoaded.
* The i18n object must contain:
* confirm, success, failed
*/
function initClaimOwnership(fileId, i18n) {
var btn = document.getElementById('claim-btn');
var msg = document.getElementById('claim-msg');
if (!btn) return;
btn.addEventListener('click', function () {
if (!confirm(i18n.confirm)) return;
btn.disabled = true;
fetch('/api/files/' + fileId + '/claim', { method: 'POST' })
.then(function (r) { return r.json().then(function (d) { return { ok: r.ok, data: d }; }); })
.then(function (result) {
if (result.ok || (result.data && result.data.status === 'already_owned')) {
if (msg) {
msg.textContent = i18n.success;
msg.style.color = '#059669';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
setTimeout(function () { location.reload(); }, 1200);
} else {
if (msg) {
msg.textContent = (result.data && result.data.detail) || i18n.failed;
msg.style.color = '#dc2626';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
btn.disabled = false;
}
})
.catch(function () {
if (msg) {
msg.textContent = i18n.failed;
msg.style.color = '#dc2626';
msg.style.display = msg.tagName === 'SPAN' ? 'inline' : 'block';
}
btn.disabled = false;
});
});
}
+462
View File
@@ -0,0 +1,462 @@
// frontend/static/js/comments.js
// Comments panel — threaded comments with @mention autocomplete
(function () {
'use strict';
var _fileId = null;
var _currentUserId = null;
var _i18n = {};
var _mentionableUsers = [];
// -------------------------------------------------------------------------
// Initialisation
// -------------------------------------------------------------------------
/**
* Bootstrap the comments panel.
* @param {number} fileId
* @param {string} currentUserId
* @param {object} i18n
*/
function initComments(fileId, currentUserId, i18n) {
_fileId = fileId;
_currentUserId = currentUserId;
_i18n = i18n || {};
_loadComments();
_loadMentionableUsers();
var form = document.getElementById('comment-form');
if (form) {
form.addEventListener('submit', function (e) {
e.preventDefault();
_submitComment(null);
});
}
var input = document.getElementById('comment-input');
if (input) {
input.addEventListener('input', function () {
_handleMentionInput(this);
});
input.addEventListener('keydown', function (e) {
_handleMentionKeydown(e);
});
// Close dropdown when clicking outside
document.addEventListener('click', function (e) {
var dropdown = document.getElementById('mention-dropdown');
if (dropdown && !dropdown.contains(e.target) && e.target !== input) {
dropdown.classList.add('hidden');
}
});
}
}
// -------------------------------------------------------------------------
// Data fetching
// -------------------------------------------------------------------------
function _loadComments() {
var container = document.getElementById('comments-list');
if (!container) return;
container.innerHTML = '<div class="comments-loading"><i class="fas fa-spinner fa-spin" aria-hidden="true"></i></div>';
fetch('/api/files/' + _fileId + '/comments')
.then(function (r) { return r.json(); })
.then(function (data) {
_renderComments(data.comments || [], container);
})
.catch(function () {
container.innerHTML = '<p class="comments-error">' + (_i18n.empty || 'No comments yet') + '</p>';
});
}
function _loadMentionableUsers() {
fetch('/api/users/mentionable')
.then(function (r) { return r.json(); })
.then(function (users) {
_mentionableUsers = users || [];
})
.catch(function () {
_mentionableUsers = [];
});
}
// -------------------------------------------------------------------------
// Rendering
// -------------------------------------------------------------------------
function _renderComments(comments, container) {
container.innerHTML = '';
if (!comments.length) {
container.innerHTML = '<p class="comments-empty"><i class="fas fa-comments" aria-hidden="true"></i> ' +
(_i18n.empty || 'No comments yet') + '</p>';
return;
}
for (var i = 0; i < comments.length; i++) {
container.appendChild(_buildCommentNode(comments[i], false));
}
}
function _buildCommentNode(comment, isReply) {
var div = document.createElement('div');
div.className = 'comment-item' + (isReply ? ' comment-reply' : '') +
(comment.is_resolved ? ' comment-resolved' : '');
div.setAttribute('data-comment-id', comment.id);
// Header
var header = document.createElement('div');
header.className = 'comment-header';
var author = document.createElement('span');
author.className = 'comment-author';
author.textContent = comment.user_id;
var time = document.createElement('time');
time.className = 'comment-time';
time.setAttribute('datetime', comment.created_at);
time.textContent = _formatDate(comment.created_at);
header.appendChild(author);
header.appendChild(time);
if (comment.is_resolved) {
var badge = document.createElement('span');
badge.className = 'comment-resolved-badge';
badge.innerHTML = '<i class="fas fa-check-circle" aria-hidden="true"></i> ' + (_i18n.resolved || 'Resolved');
header.appendChild(badge);
}
div.appendChild(header);
// Body
var bodyDiv = document.createElement('div');
bodyDiv.className = 'comment-body';
bodyDiv.id = 'comment-body-' + comment.id;
bodyDiv.innerHTML = _renderMentions(comment.body);
div.appendChild(bodyDiv);
// Actions
var actions = document.createElement('div');
actions.className = 'comment-actions';
// Reply button (only for top-level)
if (!isReply) {
var replyBtn = document.createElement('button');
replyBtn.type = 'button';
replyBtn.className = 'comment-action-btn';
replyBtn.innerHTML = '<i class="fas fa-reply" aria-hidden="true"></i> ' + (_i18n.add_reply || 'Reply');
replyBtn.setAttribute('aria-label', _i18n.add_reply || 'Reply');
replyBtn.addEventListener('click', function () { _showReplyForm(comment.id, div); });
actions.appendChild(replyBtn);
// Resolve / Unresolve
var resolveBtn = document.createElement('button');
resolveBtn.type = 'button';
resolveBtn.className = 'comment-action-btn';
if (comment.is_resolved) {
resolveBtn.innerHTML = '<i class="fas fa-undo" aria-hidden="true"></i> ' + (_i18n.unresolve || 'Reopen');
resolveBtn.setAttribute('aria-label', _i18n.unresolve || 'Reopen');
} else {
resolveBtn.innerHTML = '<i class="fas fa-check" aria-hidden="true"></i> ' + (_i18n.resolve || 'Resolve');
resolveBtn.setAttribute('aria-label', _i18n.resolve || 'Resolve');
}
resolveBtn.addEventListener('click', function () { _toggleResolve(comment.id, !comment.is_resolved); });
actions.appendChild(resolveBtn);
}
// Edit (author only)
if (comment.user_id === _currentUserId) {
var editBtn = document.createElement('button');
editBtn.type = 'button';
editBtn.className = 'comment-action-btn';
editBtn.innerHTML = '<i class="fas fa-edit" aria-hidden="true"></i> ' + (_i18n.edit || 'Edit');
editBtn.setAttribute('aria-label', _i18n.edit || 'Edit');
editBtn.addEventListener('click', function () { _showEditForm(comment.id, comment.body, div); });
actions.appendChild(editBtn);
// Delete
var deleteBtn = document.createElement('button');
deleteBtn.type = 'button';
deleteBtn.className = 'comment-action-btn comment-action-btn--danger';
deleteBtn.innerHTML = '<i class="fas fa-trash" aria-hidden="true"></i>';
deleteBtn.setAttribute('aria-label', 'Delete comment');
deleteBtn.addEventListener('click', function () { _deleteComment(comment.id); });
actions.appendChild(deleteBtn);
}
div.appendChild(actions);
// Replies
if (comment.replies && comment.replies.length) {
var repliesDiv = document.createElement('div');
repliesDiv.className = 'comment-replies';
for (var j = 0; j < comment.replies.length; j++) {
repliesDiv.appendChild(_buildCommentNode(comment.replies[j], true));
}
div.appendChild(repliesDiv);
}
return div;
}
function _renderMentions(text) {
if (!text) return '';
// Escape HTML first
var escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
// Highlight @mentions
return escaped.replace(/@([\w.\-]+)/g, '<span class="comment-mention">@$1</span>');
}
function _formatDate(iso) {
if (!iso) return '';
try {
var d = new Date(iso);
return d.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
} catch (_e) {
return iso;
}
}
// -------------------------------------------------------------------------
// Actions
// -------------------------------------------------------------------------
function _submitComment(parentId) {
var inputId = parentId ? 'reply-input-' + parentId : 'comment-input';
var input = document.getElementById(inputId);
if (!input) return;
var body = input.value.trim();
if (!body) return;
var payload = { body: body };
if (parentId) payload.parent_id = parentId;
fetch('/api/files/' + _fileId + '/comments', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
return r.json();
})
.then(function () {
input.value = '';
_loadComments();
})
.catch(function () {
// Silently fail — the CSRF wrapper in common.js handles token injection
});
}
function _toggleResolve(commentId, resolve) {
fetch('/api/files/' + _fileId + '/comments/' + commentId + '/resolve', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_resolved: resolve }),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadComments();
})
.catch(function () {});
}
function _deleteComment(commentId) {
if (!window.confirm(_i18n.delete_confirm || 'Are you sure you want to delete this comment?')) return;
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
method: 'DELETE',
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadComments();
})
.catch(function () {});
}
function _showReplyForm(commentId, containerNode) {
// Remove existing reply forms
var existing = containerNode.querySelector('.comment-reply-form');
if (existing) { existing.remove(); return; }
var form = document.createElement('div');
form.className = 'comment-reply-form';
var textarea = document.createElement('textarea');
textarea.id = 'reply-input-' + commentId;
textarea.className = 'comment-textarea';
textarea.placeholder = _i18n.reply_placeholder || 'Write a reply...';
textarea.rows = 2;
textarea.setAttribute('aria-label', _i18n.reply_placeholder || 'Write a reply...');
var submitBtn = document.createElement('button');
submitBtn.type = 'button';
submitBtn.className = 'comment-submit-btn';
submitBtn.textContent = _i18n.add_reply || 'Reply';
submitBtn.addEventListener('click', function () { _submitComment(commentId); });
form.appendChild(textarea);
form.appendChild(submitBtn);
// Insert before the replies section or at end
var repliesDiv = containerNode.querySelector('.comment-replies');
if (repliesDiv) {
containerNode.insertBefore(form, repliesDiv);
} else {
containerNode.appendChild(form);
}
textarea.focus();
}
function _showEditForm(commentId, currentBody, containerNode) {
var bodyDiv = document.getElementById('comment-body-' + commentId);
if (!bodyDiv) return;
// Already editing?
if (bodyDiv.querySelector('.comment-edit-form')) return;
var originalHTML = bodyDiv.innerHTML;
bodyDiv.innerHTML = '';
var form = document.createElement('div');
form.className = 'comment-edit-form';
var textarea = document.createElement('textarea');
textarea.className = 'comment-textarea';
textarea.value = currentBody;
textarea.rows = 3;
textarea.setAttribute('aria-label', _i18n.edit || 'Edit');
var btns = document.createElement('div');
btns.className = 'comment-edit-btns';
var saveBtn = document.createElement('button');
saveBtn.type = 'button';
saveBtn.className = 'comment-submit-btn';
saveBtn.textContent = _i18n.save || 'Save';
saveBtn.addEventListener('click', function () {
var newBody = textarea.value.trim();
if (!newBody) return;
fetch('/api/files/' + _fileId + '/comments/' + commentId, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: newBody }),
})
.then(function (r) {
if (!r.ok) throw new Error('Failed');
_loadComments();
})
.catch(function () {
bodyDiv.innerHTML = originalHTML;
});
});
var cancelBtn = document.createElement('button');
cancelBtn.type = 'button';
cancelBtn.className = 'comment-cancel-btn';
cancelBtn.textContent = _i18n.cancel || 'Cancel';
cancelBtn.addEventListener('click', function () {
bodyDiv.innerHTML = originalHTML;
});
btns.appendChild(cancelBtn);
form.appendChild(textarea);
form.appendChild(btns);
bodyDiv.appendChild(form);
textarea.focus();
}
// -------------------------------------------------------------------------
// @mention autocomplete
// -------------------------------------------------------------------------
function _handleMentionInput(input) {
var val = input.value;
var cursorPos = input.selectionStart;
var textBefore = val.substring(0, cursorPos);
var match = textBefore.match(/@([\w.\-]*)$/);
var dropdown = document.getElementById('mention-dropdown');
if (!dropdown) return;
if (!match) {
dropdown.classList.add('hidden');
return;
}
var query = match[1].toLowerCase();
var filtered = _mentionableUsers.filter(function (u) {
return u.user_id.toLowerCase().indexOf(query) !== -1 ||
(u.display_name && u.display_name.toLowerCase().indexOf(query) !== -1);
}).slice(0, 8);
if (!filtered.length) {
dropdown.classList.add('hidden');
return;
}
dropdown.innerHTML = '';
for (var i = 0; i < filtered.length; i++) {
(function (user) {
var item = document.createElement('button');
item.type = 'button';
item.className = 'mention-item';
item.setAttribute('role', 'option');
item.innerHTML = '<span class="mention-user-id">' + _escapeHtml(user.user_id) + '</span>' +
(user.display_name ? '<span class="mention-display-name">' + _escapeHtml(user.display_name) + '</span>' : '');
item.addEventListener('click', function () {
_insertMention(input, match.index, cursorPos, user.user_id);
dropdown.classList.add('hidden');
});
dropdown.appendChild(item);
})(filtered[i]);
}
dropdown.classList.remove('hidden');
}
function _handleMentionKeydown(e) {
var dropdown = document.getElementById('mention-dropdown');
if (!dropdown || dropdown.classList.contains('hidden')) return;
if (e.key === 'Escape') {
dropdown.classList.add('hidden');
e.preventDefault();
} else if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
e.preventDefault();
var items = dropdown.querySelectorAll('.mention-item');
var focused = dropdown.querySelector('.mention-item:focus');
var idx = Array.prototype.indexOf.call(items, focused);
if (e.key === 'ArrowDown') {
idx = (idx + 1) % items.length;
} else {
idx = idx <= 0 ? items.length - 1 : idx - 1;
}
items[idx].focus();
} else if (e.key === 'Enter' || e.key === 'Tab') {
var active = dropdown.querySelector('.mention-item:focus');
if (active) {
active.click();
e.preventDefault();
}
}
}
function _insertMention(input, matchStart, cursorPos, userId) {
var before = input.value.substring(0, matchStart);
var after = input.value.substring(cursorPos);
input.value = before + '@' + userId + ' ' + after;
var newPos = matchStart + userId.length + 2;
input.setSelectionRange(newPos, newPos);
input.focus();
}
function _escapeHtml(str) {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&#039;');
}
// Expose
window.initComments = initComments;
})();
+224
View File
@@ -0,0 +1,224 @@
/**
* sharing.js File sharing management UI.
*
* Renders the current shares for a document and lets the file owner
* add new shares, change roles, or revoke access.
*
* Usage:
* initSharing(fileId, i18n)
*
* The i18n object is expected to contain all keys used below.
*/
/* global fetch */
(function () {
'use strict';
var _fileId = null;
var _i18n = {};
// ── DOM helpers ──────────────────────────────────────────────────────────
function _el(id) {
return document.getElementById(id);
}
function _esc(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
function _t(key) {
return _i18n[key] || key;
}
// ── API helpers ──────────────────────────────────────────────────────────
function _apiUrl(suffix) {
return '/api/files/' + _fileId + suffix;
}
function _fetchShares() {
return fetch(_apiUrl('/shares'), { credentials: 'same-origin' })
.then(function (r) { return r.json(); });
}
function _addShare(userId, role) {
return fetch(_apiUrl('/shares'), {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ shared_with_user_id: userId, role: role }),
}).then(function (r) {
return r.json().then(function (body) {
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
return body;
});
});
}
function _updateRole(shareId, role) {
return fetch(_apiUrl('/shares/' + shareId), {
method: 'PUT',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: role }),
}).then(function (r) {
return r.json().then(function (body) {
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
return body;
});
});
}
function _revokeShare(shareId) {
return fetch(_apiUrl('/shares/' + shareId), {
method: 'DELETE',
credentials: 'same-origin',
}).then(function (r) {
return r.json().then(function (body) {
if (!r.ok) throw new Error((body && body.detail) || r.statusText);
return body;
});
});
}
// ── Render ───────────────────────────────────────────────────────────────
function _renderShares(shares) {
var list = _el('sharing-list');
if (!list) return;
if (!shares || shares.length === 0) {
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('no_shares')) + '</p>';
return;
}
var rows = shares.map(function (s) {
var roleLabel = s.role === 'editor' ? _t('role_editor') : _t('role_viewer');
return (
'<div style="display:flex;align-items:center;justify-content:space-between;gap:0.5rem;padding:0.5rem 0;border-bottom:1px solid #f1f5f9;">' +
'<span style="font-size:0.875rem;color:#334155;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;" ' +
'aria-label="' + _esc(_t('user_id_label') + ': ' + (s.display_name || s.user_id)) + '" ' +
'title="' + _esc(s.user_id) + '">' +
_esc(s.display_name || s.user_id) +
'</span>' +
'<select' +
' data-share-id="' + _esc(s.share_id) + '"' +
' class="sharing-role-select"' +
' aria-label="' + _esc(_t('change_role')) + '"' +
' style="padding:0.25rem 0.5rem;border:1px solid #cbd5e1;border-radius:0.25rem;font-size:0.8rem;background:#fff;"' +
'>' +
'<option value="viewer"' + (s.role === 'viewer' ? ' selected' : '') + '>' + _esc(_t('role_viewer')) + '</option>' +
'<option value="editor"' + (s.role === 'editor' ? ' selected' : '') + '>' + _esc(_t('role_editor')) + '</option>' +
'</select>' +
'<button' +
' data-share-id="' + _esc(s.share_id) + '"' +
' class="sharing-revoke-btn"' +
' aria-label="' + _esc(_t('revoke')) + '"' +
' title="' + _esc(_t('revoke')) + '"' +
' style="padding:0.25rem 0.5rem;background:#fee2e2;color:#b91c1c;border:1px solid #fca5a5;border-radius:0.25rem;font-size:0.8rem;cursor:pointer;"' +
'>' +
'<i class="fas fa-user-minus" aria-hidden="true"></i>' +
'</button>' +
'</div>'
);
});
list.innerHTML = rows.join('');
// Role change handlers
list.querySelectorAll('.sharing-role-select').forEach(function (sel) {
sel.addEventListener('change', function () {
var shareId = sel.getAttribute('data-share-id');
var newRole = sel.value;
_updateRole(shareId, newRole)
.then(function () { _loadAndRender(); })
.catch(function (err) { _showError(err.message); });
});
});
// Revoke handlers
list.querySelectorAll('.sharing-revoke-btn').forEach(function (btn) {
btn.addEventListener('click', function () {
if (!window.confirm(_t('revoke_confirm'))) return;
var shareId = btn.getAttribute('data-share-id');
_revokeShare(shareId)
.then(function () { _loadAndRender(); })
.catch(function (err) { _showError(err.message); });
});
});
}
function _loadAndRender() {
var list = _el('sharing-list');
if (!list) return;
list.innerHTML = '<p style="color:#64748b;font-size:0.875rem;">' + _esc(_t('loading')) + '</p>';
_fetchShares()
.then(function (data) {
// GET /files/{id}/shares returns an array; /files/{id}/shared-with also returns array
var shares = Array.isArray(data) ? data : (data.shares || []);
// Normalise keys: shares list uses share_id, but the shares endpoint returns id
shares = shares.map(function (s) {
return {
share_id: s.share_id || s.id,
user_id: s.user_id || s.shared_with_user_id,
display_name: s.display_name || s.shared_with_user_id || s.user_id,
role: s.role,
};
});
_renderShares(shares);
})
.catch(function (err) {
if (list) list.innerHTML = '<p style="color:#ef4444;font-size:0.875rem;">' + _esc(err.message) + '</p>';
});
}
function _showError(msg) {
var el = _el('sharing-form-error');
if (!el) return;
el.textContent = msg;
el.style.display = 'block';
setTimeout(function () { el.style.display = 'none'; }, 5000);
}
// ── Init ─────────────────────────────────────────────────────────────────
function initSharing(fileId, i18n) {
_fileId = fileId;
_i18n = i18n || {};
_loadAndRender();
var form = _el('sharing-form');
if (!form) return;
form.addEventListener('submit', function (e) {
e.preventDefault();
var userInput = _el('share-user-input');
var roleInput = _el('share-role-input');
var userId = userInput ? userInput.value.trim() : '';
var role = roleInput ? roleInput.value : 'viewer';
if (!userId) {
_showError(_t('error_empty_user'));
return;
}
_addShare(userId, role)
.then(function () {
if (userInput) userInput.value = '';
_loadAndRender();
})
.catch(function (err) { _showError(err.message); });
});
}
// Expose
window.initSharing = initSharing;
})();
+11 -3
View File
@@ -433,9 +433,17 @@ function _uploadSingleFile(file, progressBar, statusEl, onTerminal) {
if (xhr.status === 200) {
const result = JSON.parse(xhr.responseText);
progressBar.style.width = '100%';
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = 'text-xs text-green-600 mt-1';
if (result.status === 'duplicate' && result.duplicate_of) {
// Exact duplicate no processing task was created
progressBar.className = 'file-progress-bar bg-yellow-400 h-2 rounded-full';
statusEl.textContent = `Duplicate already processed (file #${result.duplicate_of.original_file_id})`;
statusEl.className = 'text-xs text-yellow-600 mt-1';
} else {
progressBar.className = 'file-progress-bar bg-green-500 h-2 rounded-full';
statusEl.textContent = `Success: Task ID: ${result.task_id}`;
statusEl.className = 'text-xs text-green-600 mt-1';
}
_onUploadSuccess();
onTerminal();
resolve({ rateLimited: false, retryAfterSeconds: 0 });
File diff suppressed because one or more lines are too long
+12
View File
@@ -0,0 +1,12 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./templates/**/*.html',
'./static/js/**/*.js',
],
darkMode: 'class',
theme: {
extend: {},
},
plugins: [],
}
+439
View File
@@ -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 %}
+117 -8
View File
@@ -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);
+34 -2
View File
@@ -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
View File
@@ -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;
}
},
+65 -3
View File
@@ -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) {
+252 -12
View File
@@ -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 %}
+945
View File
@@ -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 &amp; 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 %}
&mdash;
<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 %}
+226
View File
@@ -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 &amp; 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 &amp; 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 &amp; Annotations</div>
<div class="nav-card-desc">Discussion &amp; 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 %}
+31 -4
View File
@@ -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 %}
+1 -1
View File
@@ -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 ──
+46 -8
View File
@@ -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) {
+53 -14
View File
@@ -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) {
+157 -6
View File
@@ -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 %}
+15 -2
View File
@@ -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.';
+90 -1
View File
@@ -315,7 +315,23 @@
"admin_users.total_count_users": "{count} users",
"admin_users.total_no_users": "No users",
"admin_users.total_one_user": "1 user",
"annotations.add": "Add annotation",
"annotations.color": "Color",
"annotations.content_placeholder": "Write an annotation...",
"annotations.delete_confirm": "Are you sure you want to delete this annotation?",
"annotations.deleted": "Annotation deleted",
"annotations.empty": "No annotations yet",
"annotations.go_to_page": "Go to page",
"annotations.heading": "Annotations",
"annotations.page": "Page",
"annotations.save": "Save",
"annotations.type_highlight": "Highlight",
"annotations.type_note": "Note",
"annotations.type_strikethrough": "Strikethrough",
"annotations.type_underline": "Underline",
"annotations.updated": "Annotation updated",
"api_tokens.col_created": "Created",
"api_tokens.col_expires": "Expires",
"api_tokens.col_last_ip": "Last IP",
"api_tokens.col_last_used": "Last Used",
"api_tokens.col_name": "Name",
@@ -327,6 +343,14 @@
"api_tokens.create_heading": "Create New Token",
"api_tokens.create_token": "Create Token",
"api_tokens.creating": "Creating…",
"api_tokens.delete": "Delete",
"api_tokens.delete_confirm": "Permanently delete this revoked token? This cannot be undone.",
"api_tokens.delete_prefix": "Permanently delete token",
"api_tokens.expires_at_label": "Expires (optional)",
"api_tokens.expires_at_placeholder": "e.g. 30, 90, 365 days",
"api_tokens.expires_in_days_label": "Token lifetime (days)",
"api_tokens.expires_never": "Never",
"api_tokens.expires_on": "Expires",
"api_tokens.heading": "API Tokens",
"api_tokens.intro": "Create personal API tokens to interact with the DocuElevate API programmatically. Use tokens for webhook uploads, CI/CD pipelines, or any script that needs to upload or retrieve documents.",
"api_tokens.loading_tokens": "Loading tokens…",
@@ -334,9 +358,13 @@
"api_tokens.no_tokens_heading": "No API tokens yet",
"api_tokens.no_tokens_help": "Create your first token above to get started.",
"api_tokens.page_title": "API Tokens DocuElevate",
"api_tokens.reactivate": "Reactivate",
"api_tokens.reactivate_confirm": "Reactivate this token? It will be usable again immediately.",
"api_tokens.reactivate_prefix": "Reactivate token",
"api_tokens.revoke": "Revoke",
"api_tokens.revoke_prefix": "Revoke token",
"api_tokens.status_active": "Active",
"api_tokens.status_expired": "Expired",
"api_tokens.status_revoked": "Revoked",
"api_tokens.table_aria": "API Tokens",
"api_tokens.token_created": "Token created successfully!",
@@ -471,6 +499,21 @@
"billing.success_heading": "You're all set!",
"billing.success_message": "Your subscription has been activated. Thank you for choosing DocuElevate!",
"billing.success_page_title": "DocuElevate - Subscription Activated",
"comments.add_comment": "Add comment",
"comments.add_reply": "Reply",
"comments.body_placeholder": "Write a comment... Use @username to mention someone",
"comments.delete_confirm": "Are you sure you want to delete this comment?",
"comments.deleted": "Comment deleted",
"comments.edit": "Edit",
"comments.empty": "No comments yet",
"comments.heading": "Comments",
"comments.mention_users": "Mention users",
"comments.reply_placeholder": "Write a reply...",
"comments.resolve": "Resolve",
"comments.resolved": "Resolved",
"comments.save": "Save",
"comments.unresolve": "Reopen",
"comments.updated": "Comment updated",
"common.actions": "Actions",
"common.active": "Active",
"common.all": "All",
@@ -575,6 +618,22 @@
"cookie_policy.s5_p3_pre": "This Cookie Policy is part of and incorporated into our",
"cookie_policy.s5_privacy_link": "Privacy Notice",
"cookie_policy.s5_terms_link": "Terms of Service",
"connections.configure": "Configure",
"connections.configured": "Connected",
"connections.description": "Configure external authentication providers like OAuth2 and SAML.",
"connections.frontend_url_note": "Note: Requires Frontend URL to be configured. Configure in System Settings.",
"connections.linked": "Linked",
"connections.mobile_upload_description": "Allow users to upload files from mobile devices by scanning a QR code.",
"connections.mobile_upload_title": "Mobile Phone Upload",
"connections.qr_code_enabled": "Enable QR Code Upload",
"connections.save_note": "Changes to authentication providers require a restart to take effect.",
"connections.sso_auto_login": "Enable SSO Auto Login",
"connections.sso_auto_login_description": "Automatically redirect to SSO login when authentication is required.",
"connections.sso_auto_login_title": "SSO Auto Login",
"connections.title": "Connections",
"connections.unconfigure": "Disconnect",
"connections.unlinked": "Unlinked",
"connections.unlinked_services": "Services",
"credentials.col_action": "Action",
"credentials.col_credential": "Credential",
"credentials.col_source": "Source",
@@ -620,6 +679,11 @@
"devices.confirm_deactivate_device": "Remove this device? It will stop receiving push notifications.",
"devices.confirm_revoke_token": "Revoke access for this device? It will need to log in again.",
"devices.deactivate_device": "Remove",
"devices.delete_device": "Delete",
"devices.delete_device_confirm": "Permanently delete this inactive device? This cannot be undone.",
"devices.delete_token": "Delete",
"devices.delete_token_confirm": "Permanently delete this revoked token? This cannot be undone.",
"devices.device_deleted_success": "Device permanently deleted.",
"devices.device_removed_success": "Device removed successfully.",
"devices.heading": "Mobile Devices",
"devices.intro": "Manage your mobile app connections and registered devices. You can revoke access for individual devices here.",
@@ -632,12 +696,16 @@
"devices.no_mobile_tokens_help": "Log in via the mobile app or scan a QR code to create a mobile token.",
"devices.page_title": "Devices DocuElevate",
"devices.qr_login_cta": "Connect a new device via QR code",
"devices.reactivate_token": "Reactivate",
"devices.reactivate_token_confirm": "Reactivate this token? The device will be able to use it again immediately.",
"devices.registered_devices_description": "Devices registered for push notifications from the DocuElevate mobile app.",
"devices.registered_devices_heading": "Registered Devices",
"devices.revoke_token": "Revoke",
"devices.status_active": "Active",
"devices.status_inactive": "Inactive",
"devices.status_revoked": "Revoked",
"devices.token_deleted_success": "Token permanently deleted.",
"devices.token_reactivated_success": "Token reactivated successfully.",
"devices.token_revoked_success": "Device token revoked successfully.",
"duplicates.file_id_label": "File ID",
"duplicates.file_id_placeholder": "e.g. 42",
@@ -1179,6 +1247,7 @@
"nav.api_docs": "API Docs",
"nav.api_tokens": "API Tokens",
"nav.backup_restore": "Backup & Restore",
"nav.connections": "Connections",
"nav.credentials": "Credentials",
"nav.dark_mode": "Dark Mode",
"nav.dashboard": "Dashboard",
@@ -1675,6 +1744,25 @@
"shared.table_aria": "Shared links",
"shared.unlimited_placeholder": "Unlimited",
"shared.your_links": "Your Shared Links",
"sharing.add_share": "Share",
"sharing.change_role": "Change role",
"sharing.error_empty_user": "Please enter a user ID to share with.",
"sharing.heading": "Share with Users",
"sharing.loading": "Loading shares…",
"sharing.no_shares": "Not shared with anyone yet.",
"sharing.revoke": "Revoke access",
"sharing.revoke_confirm": "Remove this user's access to the file?",
"sharing.role_editor": "Editor",
"sharing.role_label": "Role",
"sharing.role_viewer": "Viewer",
"sharing.user_id_label": "User ID or email",
"sharing.user_id_placeholder": "e.g. alice@example.com",
"file.owner_label": "Owner",
"file.owner_unowned": "Unowned",
"file.claim_ownership": "Claim Ownership",
"file.claim_ownership_confirm": "Claim this document as yours? You will become the owner and can manage sharing.",
"file.claim_ownership_success": "You are now the owner of this document.",
"file.claim_ownership_failed": "Could not claim ownership. The document may already have an owner.",
"similarity.backfill_auto": "The background task will compute them automatically every 5 minutes, or you can",
"similarity.files_missing_text": "file(s) have OCR text but no embedding yet.",
"similarity.find_pairs_btn": "Find Pairs",
@@ -1891,5 +1979,6 @@
"upload.uploading": "Uploading...",
"upload.url_description": "Enter a direct link to a file (PDF, Office documents, or images)",
"upload.url_label": "File URL",
"upload.url_placeholder": "https://example.com/document.pdf"
"upload.url_placeholder": "https://example.com/document.pdf",
"annotations.type": "Type"
}