fix: merge main branch and renumber migration 037→040

Resolve all merge conflicts between our automation feature branch and
current main (v0.163.0, 920 commits ahead).

Conflicts resolved:
- app/api/__init__.py: add automation_router alongside main's new routers
  (classification_rules, qr_auth, sessions, system_reset)
- app/config.py: add main's new settings (dropbox_use_global_credentials,
  factory_reset_on_startup, enable_factory_reset)
- app/models.py: add main's new models (ClassificationRuleModel, UserSession,
  QRLoginChallenge, SharePoint integration type)
- app/utils/settings_service.py: merge automation_hooks_enabled with main's
  new metadata entries
- docs/API.md: merge automation API docs with main's classification rules docs
- docs/ConfigurationGuide.md: add factory reset settings
- tests/conftest.py: import both AutomationHook and new main models

Migration renumbered:
- 037_add_automation_hooks → 040_add_automation_hooks
- down_revision: 039_add_classification_rules (was 036_add_document_translation_fields)
- Chain: 036 → 037 → 038 → 039 → 040 (automation hooks)

For all non-automation files with conflicts, main's version was taken since
our branch did not modify those files (conflicts were from a stale prior merge).

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
Agent-Logs-Url: https://github.com/christianlouis/DocuElevate/sessions/cb62f012-3b69-4415-835e-3857ce3e9f45
This commit is contained in:
copilot-swe-agent[bot]
2026-03-20 23:54:04 +00:00
parent d1ebac74a1
commit e518bce922
153 changed files with 16306 additions and 922 deletions
+15
View File
@@ -215,6 +215,9 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
linksDiv.appendChild(
_makeMenuLink('/api-tokens', 'fas fa-key text-yellow-500', window.__i18n.apiTokens || 'API Tokens', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/devices', 'fas fa-mobile-alt text-blue-500', window.__i18n.devices || 'Devices', 'text-gray-700')
);
linksDiv.appendChild(
_makeMenuLink('/shared-links', 'fas fa-share-alt text-blue-400', window.__i18n.sharedLinks || 'Shared Links', 'text-gray-700')
);
@@ -311,6 +314,18 @@ function _makeMenuLink(href, iconClass, label, extraClasses = '') {
tokensLink.appendChild(document.createTextNode(window.__i18n.apiTokens || 'API Tokens'));
mobileAuthSection.appendChild(tokensLink);
// Devices link
const devicesLink = document.createElement('a');
devicesLink.href = '/devices';
devicesLink.className =
'flex items-center px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50';
const devicesIcon = document.createElement('i');
devicesIcon.className = 'fas fa-mobile-alt mr-2 text-blue-500';
devicesIcon.setAttribute('aria-hidden', 'true');
devicesLink.appendChild(devicesIcon);
devicesLink.appendChild(document.createTextNode(window.__i18n.devices || 'Devices'));
mobileAuthSection.appendChild(devicesLink);
// Shared Links link
const sharedLinksLink = document.createElement('a');
sharedLinksLink.href = '/shared-links';
+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 });
+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);
+11
View File
@@ -177,6 +177,11 @@
<a href="/admin/backup" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-database w-4 mr-2 text-green-600" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
</a>
{% if enable_factory_reset %}
<a href="/admin/system-reset" role="menuitem" class="flex items-center px-4 py-2 text-sm text-red-600 hover:bg-red-50">
<i class="fas fa-skull-crossbones w-4 mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
</a>
{% endif %}
<a href="/admin/audit-logs" role="menuitem" class="flex items-center px-4 py-2 text-sm text-gray-700 hover:bg-gray-100">
<i class="fas fa-shield-halved w-4 mr-2 text-indigo-500" aria-hidden="true"></i> Audit Logs
</a>
@@ -445,6 +450,11 @@
<a href="/admin/backup" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50">
<i class="fas fa-database mr-2 text-green-500" aria-hidden="true"></i> {{ _("nav.backup_restore") }}
</a>
{% if enable_factory_reset %}
<a href="/admin/system-reset" class="block px-3 py-3 rounded-md text-base font-medium text-red-600 hover:text-red-800 hover:bg-red-50">
<i class="fas fa-skull-crossbones mr-2 text-red-500" aria-hidden="true"></i> {{ _("nav.system_reset") }}
</a>
{% endif %}
<a href="/status" class="block px-3 py-3 rounded-md text-base font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-50"
{% if request and request.url.path == '/status' %}aria-current="page"{% endif %}>
<i class="fas fa-circle-dot mr-2 text-gray-400" aria-hidden="true"></i> {{ _("nav.status") }}
@@ -560,6 +570,7 @@
profileSettings: {{ _("nav.profile_settings") | tojson }},
mySubscription: {{ _("nav.my_subscription") | tojson }},
apiTokens: {{ _("nav.api_tokens") | tojson }},
devices: {{ _("nav.devices") | tojson }},
sharedLinks: {{ _("nav.shared_links") | tojson }},
signOut: {{ _("nav.sign_out") | tojson }},
logIn: {{ _("nav.login") | tojson }},
+459
View File
@@ -0,0 +1,459 @@
{% extends "base.html" %}
{% block title %}{{ _("devices.page_title") }}{% endblock %}
{% block content %}
<div x-data="devicesPage()" x-init="init()" class="container mx-auto px-4 py-8 max-w-6xl">
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<header class="mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center gap-2">
<i class="fas fa-mobile-alt text-blue-500" aria-hidden="true"></i>
{{ _("devices.heading") }}
</h1>
<p class="mt-2 text-gray-600 dark:text-gray-400 text-sm leading-relaxed max-w-2xl">
{{ _("devices.intro") }}
</p>
</header>
<!-- ── Mobile App Tokens ──────────────────────────────────────────────── -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="mobile-tokens-heading">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="mobile-tokens-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-key text-yellow-500 mr-2" aria-hidden="true"></i>{{ _("devices.mobile_tokens_heading") }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.mobile_tokens_description") }}</p>
</div>
<!-- Loading -->
<template x-if="loadingTokens">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
<p class="text-sm">{{ _("devices.loading") }}</p>
</div>
</template>
<!-- Empty state -->
<template x-if="!loadingTokens && mobileTokens.length === 0">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-mobile-alt text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
<p class="font-medium">{{ _("devices.no_mobile_tokens") }}</p>
<p class="text-sm mt-1">{{ _("devices.no_mobile_tokens_help") }}</p>
</div>
</template>
<!-- Tokens table -->
<template x-if="!loadingTokens && mobileTokens.length > 0">
<div class="overflow-x-auto">
<table class="w-full text-sm" aria-label="{{ _('devices.mobile_tokens_heading') }}">
<thead>
<tr class="bg-gray-50 dark:bg-gray-750 text-left">
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_device") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_token_prefix") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_created") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_last_used") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider">{{ _("devices.col_status") }}</th>
<th scope="col" class="px-4 py-3 font-medium text-gray-500 dark:text-gray-400 uppercase text-xs tracking-wider sr-only">{{ _("common.actions") }}</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="token in mobileTokens" :key="token.id">
<tr class="hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<td class="px-4 py-3 whitespace-nowrap">
<div class="flex items-center gap-2">
<i class="fas fa-mobile-alt text-gray-400" aria-hidden="true"></i>
<span class="font-medium text-gray-900 dark:text-white" x-text="formatDeviceName(token.name)"></span>
</div>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<code class="bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded text-xs font-mono" x-text="token.token_prefix + '…'"></code>
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400" x-text="formatDate(token.created_at)"></td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
<span x-text="token.last_used_at ? formatDate(token.last_used_at) : '—'"></span>
<span x-show="token.last_used_ip" class="block text-xs text-gray-400 mt-0.5">
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="token.last_used_ip"></span>
</span>
</td>
<td class="px-4 py-3 whitespace-nowrap">
<span
class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium"
:class="token.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400'"
x-text="token.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_revoked') }}'"
></span>
</td>
<td class="px-4 py-3 whitespace-nowrap text-right">
<div class="inline-flex items-center gap-1">
<!-- Revoke button shown for active tokens -->
<button
x-show="token.is_active"
type="button"
@click="revokeToken(token)"
:disabled="actingToken === token.id"
:title="'{{ _('devices.revoke_token') }}'"
class="inline-flex items-center justify-center w-11 h-11 text-sm text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
:aria-label="'{{ _('devices.revoke_token') }} ' + token.name"
>
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-sign-out-alt'" aria-hidden="true"></i>
</button>
<!-- Reactivate button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="reactivateMobileToken(token)"
:disabled="actingToken === token.id"
:title="'{{ _('devices.reactivate_token') }}'"
class="inline-flex items-center justify-center w-11 h-11 text-sm text-green-600 hover:text-green-800
dark:text-green-400 dark:hover:text-green-300 hover:bg-green-50 dark:hover:bg-green-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-green-500 disabled:opacity-50 transition-colors"
:aria-label="'{{ _('devices.reactivate_token') }} ' + token.name"
>
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-redo'" aria-hidden="true"></i>
</button>
<!-- Delete button shown for revoked tokens -->
<button
x-show="!token.is_active"
type="button"
@click="deleteMobileToken(token)"
:disabled="actingToken === token.id"
:title="'{{ _('devices.delete_token') }}'"
class="inline-flex items-center justify-center w-11 h-11 text-sm text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
:aria-label="'{{ _('devices.delete_token') }} ' + token.name"
>
<i :class="actingToken === token.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" aria-hidden="true"></i>
</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<!-- Error -->
<template x-if="tokenError">
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<span x-text="tokenError"></span>
</div>
</template>
</section>
<!-- ── Registered Devices (Push Notifications) ────────────────────────── -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden mb-6" aria-labelledby="devices-heading">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 id="devices-heading" class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-bell text-purple-500 mr-2" aria-hidden="true"></i>{{ _("devices.registered_devices_heading") }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ _("devices.registered_devices_description") }}</p>
</div>
<!-- Loading -->
<template x-if="loadingDevices">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-spinner fa-spin text-2xl mb-2" aria-hidden="true"></i>
<p class="text-sm">{{ _("devices.loading") }}</p>
</div>
</template>
<!-- Empty state -->
<template x-if="!loadingDevices && devices.length === 0">
<div class="p-8 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-bell-slash text-4xl mb-3 text-gray-300 dark:text-gray-600" aria-hidden="true"></i>
<p class="font-medium">{{ _("devices.no_devices") }}</p>
<p class="text-sm mt-1">{{ _("devices.no_devices_help") }}</p>
</div>
</template>
<!-- Devices list -->
<template x-if="!loadingDevices && devices.length > 0">
<div class="divide-y divide-gray-200 dark:divide-gray-700">
<template x-for="device in devices" :key="device.id">
<div class="flex items-center justify-between px-6 py-4 hover:bg-gray-50 dark:hover:bg-gray-750 transition-colors">
<div class="flex items-center gap-3 min-w-0">
<i
:class="device.platform === 'ios' ? 'fab fa-apple' :
device.platform === 'android' ? 'fab fa-android text-green-500' :
'fas fa-globe'"
class="text-lg text-gray-400 flex-shrink-0"
aria-hidden="true"
></i>
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
<span x-text="device.device_name || 'Unknown Device'"></span>
<span
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium"
:class="device.is_active ? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400' : 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'"
x-text="device.is_active ? '{{ _('devices.status_active') }}' : '{{ _('devices.status_inactive') }}'"
></span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3 mt-0.5">
<span>
<i class="fas fa-microchip mr-1" aria-hidden="true"></i>
<span x-text="device.platform.charAt(0).toUpperCase() + device.platform.slice(1)"></span>
</span>
<span x-show="device.last_seen_at">
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("devices.col_last_seen") }}:
<span x-text="formatDate(device.last_seen_at)"></span>
</span>
<span>
<i class="fas fa-calendar mr-1" aria-hidden="true"></i>
<span x-text="formatDate(device.created_at)"></span>
</span>
</div>
</div>
</div>
<button
x-show="device.is_active"
type="button"
@click="deactivateDevice(device)"
:disabled="actingDevice === device.id"
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('devices.deactivate_device') }} ' + (device.device_name || 'device')"
>
<i :class="actingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-power-off'" class="mr-1" aria-hidden="true"></i>
{{ _("devices.deactivate_device") }}
</button>
<!-- Delete button shown for already-inactive devices -->
<button
x-show="!device.is_active"
type="button"
@click="deleteDevice(device)"
:disabled="actingDevice === device.id"
class="flex-shrink-0 inline-flex items-center px-3 py-1.5 text-sm font-medium text-red-600 hover:text-red-800
dark:text-red-400 dark:hover:text-red-300 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md
focus:outline-none focus:ring-2 focus:ring-red-500 disabled:opacity-50 transition-colors"
style="min-height:36px; min-width:44px;"
:aria-label="'{{ _('devices.delete_device') }} ' + (device.device_name || 'device')"
>
<i :class="actingDevice === device.id ? 'fas fa-spinner fa-spin' : 'fas fa-trash-alt'" class="mr-1" aria-hidden="true"></i>
{{ _("devices.delete_device") }}
</button>
</div>
</template>
</div>
</template>
<!-- Error -->
<template x-if="deviceError">
<div class="m-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-red-700 dark:text-red-400 p-3 rounded text-sm" role="alert">
<i class="fas fa-exclamation-triangle mr-1" aria-hidden="true"></i>
<span x-text="deviceError"></span>
</div>
</template>
</section>
<!-- ── QR Login CTA ───────────────────────────────────────────────────── -->
<div class="text-center">
<a
href="/qr-login"
class="inline-flex items-center px-5 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium
rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 transition-colors"
style="min-height:44px;"
>
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
{{ _("devices.qr_login_cta") }}
</a>
</div>
<!-- ── Status banner ──────────────────────────────────────────────────── -->
<div
x-show="banner.visible"
x-transition
class="mt-6 rounded-lg p-3 text-sm"
:class="banner.error
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
role="alert"
aria-live="polite"
>
<span x-text="banner.message"></span>
</div>
</div>
<script>
function devicesPage() {
const csrfToken = '{{ csrf_token | default("") }}';
return {
mobileTokens: [],
devices: [],
loadingTokens: true,
loadingDevices: true,
actingToken: null,
actingDevice: null,
tokenError: null,
deviceError: null,
banner: { visible: false, error: false, message: '' },
async init() {
await Promise.all([this.loadMobileTokens(), this.loadDevices()]);
},
async loadMobileTokens() {
this.loadingTokens = true;
this.tokenError = null;
try {
const res = await fetch('/api/api-tokens/mobile', {
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) throw new Error('Failed to load mobile tokens');
this.mobileTokens = await res.json();
} catch (e) {
this.tokenError = e.message;
} finally {
this.loadingTokens = false;
}
},
async loadDevices() {
this.loadingDevices = true;
this.deviceError = null;
try {
const res = await fetch('/api/mobile/devices', {
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) throw new Error('Failed to load devices');
this.devices = await res.json();
} catch (e) {
this.deviceError = e.message;
} finally {
this.loadingDevices = false;
}
},
async revokeToken(token) {
if (!confirm({{ _("devices.confirm_revoke_token") | tojson }})) return;
this.actingToken = token.id;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to revoke token');
}
await this.loadMobileTokens();
this._showBanner({{ _("devices.token_revoked_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingToken = null;
}
},
async reactivateMobileToken(token) {
if (!confirm({{ _("devices.reactivate_token_confirm") | tojson }})) return;
this.actingToken = token.id;
try {
const res = await fetch(`/api/api-tokens/${token.id}/reactivate`, {
method: 'POST',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to reactivate token');
}
await this.loadMobileTokens();
this._showBanner({{ _("devices.token_reactivated_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingToken = null;
}
},
async deleteMobileToken(token) {
if (!confirm({{ _("devices.delete_token_confirm") | tojson }})) return;
this.actingToken = token.id;
try {
const res = await fetch(`/api/api-tokens/${token.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to delete token');
}
await this.loadMobileTokens();
this._showBanner({{ _("devices.token_deleted_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingToken = null;
}
},
async deactivateDevice(device) {
if (!confirm({{ _("devices.confirm_deactivate_device") | tojson }})) return;
this.actingDevice = device.id;
try {
const res = await fetch(`/api/mobile/devices/${device.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to remove device');
}
await this.loadDevices();
this._showBanner({{ _("devices.device_removed_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingDevice = null;
}
},
async deleteDevice(device) {
if (!confirm({{ _("devices.delete_device_confirm") | tojson }})) return;
this.actingDevice = device.id;
try {
const res = await fetch(`/api/mobile/devices/${device.id}`, {
method: 'DELETE',
headers: { 'X-CSRF-Token': csrfToken },
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.detail || 'Failed to delete device');
}
await this.loadDevices();
this._showBanner({{ _("devices.device_deleted_success") | tojson }}, false);
} catch (e) {
this._showBanner(e.message, true);
} finally {
this.actingDevice = null;
}
},
/** Extract the device name from the full token name (e.g. "Mobile App iPhone 15 Pro" → "iPhone 15 Pro"). */
formatDeviceName(name) {
if (!name) return 'Unknown Device';
// Match either em dash () or hyphen (-) separators used by the mobile flows.
const match = name.match(/[\-]\s*(.+)$/);
return match ? match[1].trim() : name;
},
formatDate(d) {
if (!d) return '—';
const dt = new Date(d);
return dt.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) +
' ' + dt.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
},
_showBanner(msg, isError) {
this.banner = { visible: true, error: isError, message: msg };
setTimeout(() => { this.banner.visible = false; }, 5000);
},
};
}
</script>
{% endblock %}
+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 %}
+1
View File
@@ -524,6 +524,7 @@
<option value="webdav" {% if storage_provider == "webdav" %}selected{% endif %}>WebDAV</option>
<option value="ftp" {% if storage_provider == "ftp" %}selected{% endif %}>FTP</option>
<option value="sftp" {% if storage_provider == "sftp" %}selected{% endif %}>SFTP</option>
<option value="sharepoint" {% if storage_provider == "sharepoint" %}selected{% endif %}>SharePoint</option>
<option value="icloud" {% if storage_provider == "icloud" %}selected{% endif %}>iCloud Drive</option>
</select>
</div>
+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) {
+9 -1
View File
@@ -33,7 +33,7 @@
"name": "Which cloud storage providers does DocuElevate support?",
"acceptedAnswer": {
"@type": "Answer",
"text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx."
"text": "DocuElevate integrates with Dropbox, Google Drive, OneDrive, SharePoint, Amazon S3, Nextcloud, WebDAV, SFTP, FTP, and Paperless-ngx."
}
},
{
@@ -188,6 +188,14 @@
</div>
</div>
<div class="flex items-start bg-white shadow rounded-lg p-5">
<i class="fab fa-microsoft text-purple-600 text-xl mr-3 mt-0.5" aria-hidden="true"></i>
<div>
<h3 class="font-semibold text-gray-800 text-sm">SharePoint</h3>
<p class="text-gray-500 text-xs">Upload to SharePoint Online document libraries via Graph API.</p>
</div>
</div>
<div class="flex items-start bg-white shadow rounded-lg p-5">
<i class="fab fa-aws text-yellow-600 text-xl mr-3 mt-0.5" aria-hidden="true"></i>
<div>
@@ -1392,7 +1392,7 @@ function integrationsDashboard() {
body: JSON.stringify({
integration_type: intg.integration_type,
config: intg.config,
credentials: creds,
credentials: creds.credentials,
}),
});
const data = await resp.json();
+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 %}
+194
View File
@@ -343,9 +343,203 @@
</section>
</template>
<!-- ── Security & Sessions card ──────────────────────────────────────── -->
<section
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
aria-labelledby="security-heading"
x-data="sessionManager()"
x-init="loadSessions()"
>
<h2 id="security-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-1">
<i class="fas fa-shield-alt text-gray-400 mr-2" aria-hidden="true"></i>{{ _("sessions.security_heading") }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
{{ _("sessions.security_subtitle") }}
</p>
<!-- Session lifetime info -->
<div class="text-xs text-gray-400 dark:text-gray-500 mb-4" x-show="lifetimeDays > 0">
<i class="fas fa-clock mr-1" aria-hidden="true"></i>
<span x-text="'{{ _("sessions.session_lifetime") }}'.replace('{days}', lifetimeDays)"></span>
</div>
<!-- Active sessions list -->
<div class="space-y-3 mb-5">
<template x-for="session in sessions" :key="session.id">
<div
class="flex items-center justify-between border border-gray-200 dark:border-gray-700 rounded-lg p-3"
:class="session.is_current ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' : ''"
>
<div class="flex items-center gap-3 min-w-0">
<i
:class="session.device_info && session.device_info.includes('iPhone') ? 'fas fa-mobile-alt' :
session.device_info && session.device_info.includes('iPad') ? 'fas fa-tablet-alt' :
session.device_info && session.device_info.includes('Android') ? 'fas fa-mobile-alt' :
session.device_info && session.device_info.includes('App') ? 'fas fa-mobile-alt' :
'fas fa-desktop'"
class="text-gray-400 text-lg flex-shrink-0"
aria-hidden="true"
></i>
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
<span x-text="session.device_info || 'Unknown Device'"></span>
<span
x-show="session.is_current"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>{{ _("sessions.current_session") }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3">
<span x-show="session.ip_address">
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="session.ip_address"></span>
</span>
<span>
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("sessions.last_active") }}
<span x-text="timeAgo(session.last_active_at)"></span>
</span>
</div>
</div>
</div>
<button
x-show="!session.is_current"
@click="revokeSession(session.id)"
class="flex-shrink-0 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium px-3 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition"
style="min-height:44px; min-width:44px;"
:aria-label="'{{ _("sessions.revoke") }}'"
>
<i class="fas fa-sign-out-alt mr-1" aria-hidden="true"></i>{{ _("sessions.revoke") }}
</button>
</div>
</template>
<p
x-show="sessions.length <= 1"
class="text-sm text-gray-500 dark:text-gray-400 italic"
>{{ _("sessions.no_other_sessions") }}</p>
</div>
<!-- Log off everywhere + QR login row -->
<div class="flex flex-col sm:flex-row gap-3">
<button
@click="revokeAllSessions()"
class="inline-flex items-center justify-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
style="min-height:44px;"
:disabled="revoking"
>
<i class="fas fa-power-off mr-2" aria-hidden="true"></i>
<span x-text="revoking ? '{{ _("profile.saving") }}' : '{{ _("sessions.log_off_everywhere") }}'"></span>
</button>
<a
href="/qr-login"
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
style="min-height:44px;"
>
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
{{ _("sessions.qr_login_link") }}
</a>
</div>
<!-- Status banner for session actions -->
<div
x-show="sessionBanner.visible"
x-transition
class="mt-4 rounded-lg p-3 text-sm"
:class="sessionBanner.error
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
role="alert"
aria-live="polite"
>
<span x-text="sessionBanner.message"></span>
</div>
</section>
</div><!-- /container -->
<script>
/* ── Session Manager Alpine component ──────────────────────────────────── */
function sessionManager() {
return {
sessions: [],
lifetimeDays: 0,
revoking: false,
sessionBanner: { visible: false, error: false, message: '' },
_csrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
},
async loadSessions() {
try {
const res = await fetch('/api/sessions/');
if (res.ok) {
const data = await res.json();
this.sessions = data.sessions || [];
this.lifetimeDays = data.session_lifetime_days || 30;
}
} catch (_e) { /* silently ignore */ }
},
async revokeSession(sessionId) {
if (!confirm({{ _("sessions.confirm_revoke_one") | tojson }})) return;
const csrf = this._csrfToken();
try {
const res = await fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
});
if (res.ok || res.status === 204) {
this.sessions = this.sessions.filter(s => s.id !== sessionId);
this._showSessionBanner({{ _("sessions.revoked_success") | tojson }}, false);
}
} catch (_e) {
this._showSessionBanner('Network error — please try again.', true);
}
},
async revokeAllSessions() {
if (!confirm({{ _("sessions.confirm_revoke_all") | tojson }})) return;
this.revoking = true;
const csrf = this._csrfToken();
try {
const res = await fetch('/api/sessions/revoke-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
},
});
if (res.ok) {
await this.loadSessions();
this._showSessionBanner({{ _("sessions.revoked_all_success") | tojson }}, false);
}
} catch (_e) {
this._showSessionBanner('Network error — please try again.', true);
} finally {
this.revoking = false;
}
},
timeAgo(dateStr) {
if (!dateStr) return 'unknown';
const now = new Date();
const then = new Date(dateStr);
const diff = Math.floor((now - then) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
},
_showSessionBanner(msg, err) {
this.sessionBanner = { visible: true, error: err, message: msg };
if (!err) setTimeout(() => { this.sessionBanner.visible = false; }, 4000);
},
};
}
/* ── Profile Settings Alpine component ─────────────────────────────────── */
function profileSettings() {
return {
// ── State ──────────────────────────────────────────────────────────────
+229
View File
@@ -0,0 +1,229 @@
{% extends "base.html" %}
{% block title %}{{ _("qr_login.page_title") }}{% endblock %}
{% block content %}
<div
x-data="qrLoginPage()"
x-init="generateChallenge()"
class="container mx-auto px-4 py-8 max-w-xl"
>
<!-- ── Header ─────────────────────────────────────────────────────────── -->
<header class="mb-8 text-center">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white flex items-center justify-center gap-2">
<i class="fas fa-qrcode text-blue-500" aria-hidden="true"></i>
{{ _("qr_login.heading") }}
</h1>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ _("qr_login.subtitle") }}
</p>
</header>
<!-- ── QR Code Card ───────────────────────────────────────────────────── -->
<section
class="bg-white dark:bg-gray-800 shadow rounded-lg p-8 mb-6 text-center"
aria-labelledby="qr-heading"
>
<!-- Pending state: show QR code -->
<template x-if="status === 'pending'">
<div>
<div
class="mx-auto mb-4 bg-white p-4 inline-block rounded-lg shadow-inner"
id="qr-container"
aria-label="{{ _('qr_login.description') }}"
>
<img
:src="qrCodeSvg"
width="256"
height="256"
alt="{{ _('qr_login.description') }}"
id="qr-image"
/>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-2">
{{ _("qr_login.description") }}
</p>
<div class="flex items-center justify-center gap-2 text-xs text-gray-400 dark:text-gray-500">
<i class="fas fa-hourglass-half animate-pulse" aria-hidden="true"></i>
<span x-text="'{{ _("qr_login.time_remaining") }}'.replace('{seconds}', countdown)"></span>
</div>
<p class="mt-3 text-sm text-blue-600 dark:text-blue-400">
<i class="fas fa-spinner fa-spin mr-1" aria-hidden="true"></i>
{{ _("qr_login.pending_message") }}
</p>
</div>
</template>
<!-- Claimed state: success -->
<template x-if="status === 'claimed'">
<div class="py-8">
<i class="fas fa-check-circle text-green-500 text-5xl mb-4" aria-hidden="true"></i>
<p class="text-lg font-semibold text-green-700 dark:text-green-400 mb-2">
{{ _("qr_login.claimed_message") }}
</p>
<p x-show="deviceName" class="text-sm text-gray-500 dark:text-gray-400"
x-text="'{{ _("qr_login.claimed_device") }}'.replace('{device_name}', deviceName)">
</p>
</div>
</template>
<!-- Expired state -->
<template x-if="status === 'expired'">
<div class="py-8">
<i class="fas fa-clock text-yellow-500 text-5xl mb-4" aria-hidden="true"></i>
<p class="text-base text-gray-700 dark:text-gray-300 mb-4">
{{ _("qr_login.expired_message") }}
</p>
<button
@click="generateChallenge()"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
style="min-height:44px;"
>
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
{{ _("qr_login.generate_new") }}
</button>
</div>
</template>
<!-- Error state -->
<template x-if="status === 'error'">
<div class="py-8">
<i class="fas fa-exclamation-triangle text-red-500 text-5xl mb-4" aria-hidden="true"></i>
<p class="text-base text-gray-700 dark:text-gray-300 mb-4" x-text="errorMsg"></p>
<button
@click="generateChallenge()"
class="inline-flex items-center px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white font-medium rounded-lg transition"
style="min-height:44px;"
>
<i class="fas fa-redo mr-2" aria-hidden="true"></i>
{{ _("qr_login.generate_new") }}
</button>
</div>
</template>
</section>
<!-- ── How it works ───────────────────────────────────────────────────── -->
<section class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 class="text-base font-semibold text-gray-900 dark:text-white mb-3">
<i class="fas fa-info-circle text-gray-400 mr-2" aria-hidden="true"></i>
{{ _("qr_login.how_it_works") }}
</h2>
<ol class="list-decimal list-inside space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li>{{ _("qr_login.step_1") }}</li>
<li>{{ _("qr_login.step_2") }}</li>
<li>{{ _("qr_login.step_3") }}</li>
</ol>
</section>
</div>
<!-- QR code is rendered server-side; no external QR library needed -->
<script>
function qrLoginPage() {
return {
status: 'loading', // loading | pending | claimed | expired | error
challengeId: null,
challengeToken: '',
qrPayload: '',
qrCodeSvg: '',
expiresAt: null,
countdown: 0,
deviceName: '',
errorMsg: '',
_pollTimer: null,
_countdownTimer: null,
_ttlSeconds: 0,
_receivedAt: null,
_csrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
},
async generateChallenge() {
this.status = 'loading';
this._stopTimers();
const csrf = this._csrfToken();
try {
const res = await fetch('/api/qr-auth/challenge', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
},
});
if (!res.ok) {
this.status = 'error';
this.errorMsg = 'Failed to generate QR code. Please try again.';
return;
}
const data = await res.json();
this.challengeId = data.challenge_id;
this.challengeToken = data.challenge_token;
this.qrPayload = data.qr_payload;
this.qrCodeSvg = data.qr_code_svg;
this.expiresAt = new Date(data.expires_at);
this._ttlSeconds = data.ttl_seconds || 120;
this._receivedAt = Date.now();
this.status = 'pending';
this.deviceName = '';
// Start polling and countdown
this._startPolling();
this._startCountdown();
} catch (_e) {
this.status = 'error';
this.errorMsg = 'Network error — please check your connection and try again.';
}
},
_startPolling() {
this._pollTimer = setInterval(async () => {
if (this.status !== 'pending') { this._stopTimers(); return; }
try {
const res = await fetch(`/api/qr-auth/challenge/${this.challengeId}/status`);
if (!res.ok) return;
const data = await res.json();
if (data.status === 'claimed') {
this.status = 'claimed';
this.deviceName = data.device_name || '';
this._stopTimers();
} else if (data.status === 'expired') {
this.status = 'expired';
this._stopTimers();
} else if (data.status === 'cancelled') {
this.status = 'expired';
this._stopTimers();
}
} catch (_e) { /* ignore transient errors */ }
}, 2000);
},
_startCountdown() {
this._updateCountdown();
this._countdownTimer = setInterval(() => {
this._updateCountdown();
if (this.countdown <= 0 && this.status === 'pending') {
this.status = 'expired';
this._stopTimers();
}
}, 1000);
},
_updateCountdown() {
if (!this._receivedAt) { this.countdown = 0; return; }
const elapsed = (Date.now() - this._receivedAt) / 1000;
const remaining = Math.max(0, Math.floor(this._ttlSeconds - elapsed));
this.countdown = remaining;
},
_stopTimers() {
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
if (this._countdownTimer) { clearInterval(this._countdownTimer); this._countdownTimer = null; }
},
};
}
</script>
{% endblock %}
+14 -1
View File
@@ -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.';
+261
View File
@@ -0,0 +1,261 @@
{% extends "base.html" %}
{% block title %}{{ _("system_reset.page_title") }} - DocuElevate{% endblock %}
{% block head_extra %}
<style>
.reset-card { transition: box-shadow 0.2s ease; }
.reset-card:hover { box-shadow: 0 4px 20px rgba(0,0,0,.08); }
.confirmation-input { font-family: 'Courier New', monospace; letter-spacing: 0.1em; text-transform: uppercase; }
</style>
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8" x-data="systemResetApp()">
<!-- Header -->
<div class="mb-6">
<div class="flex items-center gap-3 mb-2">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-skull-crossbones mr-2 text-red-600" aria-hidden="true"></i>
{{ _("system_reset.heading") }}
</h1>
</div>
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ _("system_reset.subtitle") }}
</p>
</div>
<!-- Factory Reset on Startup banner -->
{% if factory_reset_on_startup %}
<div class="mb-6 rounded-lg border border-yellow-300 bg-yellow-50 dark:bg-yellow-900/20 dark:border-yellow-700 p-4" role="alert">
<div class="flex items-start gap-3">
<i class="fas fa-exclamation-triangle text-yellow-600 mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-semibold text-yellow-800 dark:text-yellow-200">{{ _("system_reset.startup_reset_active") }}</p>
<p class="text-sm text-yellow-700 dark:text-yellow-300 mt-1">{{ _("system_reset.startup_reset_desc") }}</p>
</div>
</div>
</div>
{% endif %}
<!-- Warning banner -->
<div class="mb-8 rounded-lg border-2 border-red-300 bg-red-50 dark:bg-red-900/20 dark:border-red-700 p-6" role="alert">
<div class="flex items-start gap-3">
<i class="fas fa-radiation text-red-600 text-2xl mt-0.5" aria-hidden="true"></i>
<div>
<p class="font-bold text-red-800 dark:text-red-200 text-lg">{{ _("system_reset.danger_zone") }}</p>
<p class="text-sm text-red-700 dark:text-red-300 mt-1">{{ _("system_reset.danger_desc") }}</p>
</div>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- Full Reset Card -->
<div class="reset-card rounded-xl border-2 border-red-200 dark:border-red-800 bg-white dark:bg-gray-800 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="h-10 w-10 rounded-full bg-red-100 dark:bg-red-900 flex items-center justify-center">
<i class="fas fa-trash-alt text-red-600" aria-hidden="true"></i>
</div>
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.full_reset_title") }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.full_reset_subtitle") }}</p>
</div>
</div>
<div class="space-y-3 mb-6">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.full_reset_desc") }}</p>
<ul class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-disc">
<li>{{ _("system_reset.full_reset_item_db") }}</li>
<li>{{ _("system_reset.full_reset_item_files") }}</li>
<li>{{ _("system_reset.full_reset_item_cache") }}</li>
<li>{{ _("system_reset.full_reset_item_settings_kept") }}</li>
</ul>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label for="fullResetConfirm" class="block text-sm font-semibold text-red-700 dark:text-red-400 mb-2">
{{ _("system_reset.type_delete") }}
</label>
<input id="fullResetConfirm"
type="text"
x-model="fullResetInput"
class="confirmation-input w-full px-3 py-2 border-2 border-red-300 dark:border-red-700 rounded-lg
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-red-500 focus:border-red-500"
placeholder="DELETE"
autocomplete="off"
aria-describedby="fullResetHelp" />
<p id="fullResetHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_delete_help") }}</p>
<button @click="executeFullReset()"
:disabled="fullResetInput !== 'DELETE' || loading"
type="button"
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
text-base font-bold rounded-lg text-white
bg-red-600 hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-red-500
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
aria-label="{{ _('system_reset.full_reset_button') }}">
<template x-if="loading && activeAction === 'full'">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<i x-show="!(loading && activeAction === 'full')" class="fas fa-trash-alt mr-2" aria-hidden="true"></i>
{{ _("system_reset.full_reset_button") }}
</button>
</div>
</div>
<!-- Reset & Re-import Card -->
<div class="reset-card rounded-xl border-2 border-orange-200 dark:border-orange-800 bg-white dark:bg-gray-800 p-6">
<div class="flex items-center gap-3 mb-4">
<div class="h-10 w-10 rounded-full bg-orange-100 dark:bg-orange-900 flex items-center justify-center">
<i class="fas fa-recycle text-orange-600" aria-hidden="true"></i>
</div>
<div>
<h2 class="text-xl font-bold text-gray-900 dark:text-white">{{ _("system_reset.reimport_title") }}</h2>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _("system_reset.reimport_subtitle") }}</p>
</div>
</div>
<div class="space-y-3 mb-6">
<p class="text-sm text-gray-700 dark:text-gray-300">{{ _("system_reset.reimport_desc") }}</p>
<ol class="text-sm text-gray-600 dark:text-gray-400 space-y-1 ml-4 list-decimal">
<li>{{ _("system_reset.reimport_step_1") }}</li>
<li>{{ _("system_reset.reimport_step_2") }}</li>
<li>{{ _("system_reset.reimport_step_3") }}</li>
</ol>
<p class="text-xs text-gray-500 dark:text-gray-400 italic">{{ _("system_reset.reimport_note") }}</p>
</div>
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
<label for="reimportConfirm" class="block text-sm font-semibold text-orange-700 dark:text-orange-400 mb-2">
{{ _("system_reset.type_reimport") }}
</label>
<input id="reimportConfirm"
type="text"
x-model="reimportInput"
class="confirmation-input w-full px-3 py-2 border-2 border-orange-300 dark:border-orange-700 rounded-lg
text-center text-lg bg-white dark:bg-gray-900 text-gray-900 dark:text-white
focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-orange-500"
placeholder="REIMPORT"
autocomplete="off"
aria-describedby="reimportHelp" />
<p id="reimportHelp" class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _("system_reset.type_reimport_help") }}</p>
<button @click="executeReimport()"
:disabled="reimportInput !== 'REIMPORT' || loading"
type="button"
class="mt-4 w-full inline-flex items-center justify-center px-4 py-3 border border-transparent
text-base font-bold rounded-lg text-white
bg-orange-600 hover:bg-orange-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-orange-500
disabled:opacity-40 disabled:cursor-not-allowed transition min-h-[44px]"
aria-label="{{ _('system_reset.reimport_button') }}">
<template x-if="loading && activeAction === 'reimport'">
<i class="fas fa-spinner fa-spin mr-2" aria-hidden="true"></i>
</template>
<i x-show="!(loading && activeAction === 'reimport')" class="fas fa-recycle mr-2" aria-hidden="true"></i>
{{ _("system_reset.reimport_button") }}
</button>
</div>
</div>
</div>
<!-- Result banner (shown after a reset completes) -->
<div x-show="resultMessage" x-cloak
class="mt-8 rounded-lg p-4"
:class="resultSuccess ? 'bg-green-50 dark:bg-green-900/20 border border-green-300 dark:border-green-700' :
'bg-red-50 dark:bg-red-900/20 border border-red-300 dark:border-red-700'"
:role="resultSuccess ? 'status' : 'alert'" aria-live="polite">
<div class="flex items-start gap-3">
<i :class="resultSuccess ? 'fas fa-check-circle text-green-600' : 'fas fa-times-circle text-red-600'" aria-hidden="true"></i>
<div>
<p class="font-semibold" :class="resultSuccess ? 'text-green-800 dark:text-green-200' : 'text-red-800 dark:text-red-200'"
x-text="resultMessage"></p>
<pre x-show="resultDetail" x-text="resultDetail"
class="mt-2 text-xs overflow-x-auto whitespace-pre-wrap"
:class="resultSuccess ? 'text-green-700 dark:text-green-300' : 'text-red-700 dark:text-red-300'"></pre>
</div>
</div>
</div>
</div>
<script>
function systemResetApp() {
const __i18n = {
successFull: {{ _("system_reset.js_success_full") | tojson }},
successReimport: {{ _("system_reset.js_success_reimport") | tojson }},
errorGeneric: {{ _("system_reset.js_error_generic") | tojson }},
};
return {
fullResetInput: '',
reimportInput: '',
loading: false,
activeAction: null,
resultMessage: null,
resultDetail: null,
resultSuccess: false,
async executeFullReset() {
if (this.fullResetInput !== 'DELETE') return;
this.loading = true;
this.activeAction = 'full';
this.resultMessage = null;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const resp = await fetch('/api/admin/system-reset/full', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ confirmation: 'DELETE' }),
});
const data = await resp.json();
if (resp.ok) {
this.resultSuccess = true;
this.resultMessage = __i18n.successFull;
this.resultDetail = JSON.stringify(data.result, null, 2);
} else {
this.resultSuccess = false;
this.resultMessage = data.detail || __i18n.errorGeneric;
}
} catch (err) {
this.resultSuccess = false;
this.resultMessage = __i18n.errorGeneric;
} finally {
this.loading = false;
this.fullResetInput = '';
}
},
async executeReimport() {
if (this.reimportInput !== 'REIMPORT') return;
this.loading = true;
this.activeAction = 'reimport';
this.resultMessage = null;
try {
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const resp = await fetch('/api/admin/system-reset/reimport', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrfToken },
body: JSON.stringify({ confirmation: 'REIMPORT' }),
});
const data = await resp.json();
if (resp.ok) {
this.resultSuccess = true;
this.resultMessage = __i18n.successReimport;
this.resultDetail = JSON.stringify(data.result, null, 2);
} else {
this.resultSuccess = false;
this.resultMessage = data.detail || __i18n.errorGeneric;
}
} catch (err) {
this.resultSuccess = false;
this.resultMessage = __i18n.errorGeneric;
} finally {
this.loading = false;
this.reimportInput = '';
}
},
};
}
</script>
{% endblock %}
+118
View File
@@ -316,6 +316,7 @@
"admin_users.total_no_users": "No users",
"admin_users.total_one_user": "1 user",
"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 +328,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 +343,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!",
@@ -608,6 +621,46 @@
"dashboard.title": "Dashboard",
"dashboard.total_files": "Total Files",
"dashboard.welcome": "Welcome to DocuElevate",
"devices.col_created": "Connected",
"devices.col_device": "Device",
"devices.col_last_ip": "Last IP",
"devices.col_last_seen": "Last Seen",
"devices.col_last_used": "Last Used",
"devices.col_platform": "Platform",
"devices.col_push_token": "Push Token",
"devices.col_status": "Status",
"devices.col_token_prefix": "Token Prefix",
"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.",
"devices.loading": "Loading devices…",
"devices.mobile_tokens_description": "These tokens were created when you logged in via the mobile app or scanned a QR code. Revoking a token will sign the device out.",
"devices.mobile_tokens_heading": "Mobile App Tokens",
"devices.no_devices": "No registered devices",
"devices.no_devices_help": "Install the DocuElevate mobile app and log in to register a device for push notifications.",
"devices.no_mobile_tokens": "No mobile app tokens",
"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",
"duplicates.find_btn": "Find",
@@ -1152,6 +1205,7 @@
"nav.dark_mode": "Dark Mode",
"nav.dashboard": "Dashboard",
"nav.developer_docs": "Developer Docs",
"nav.devices": "Devices",
"nav.duplicates": "Duplicates",
"nav.file_manager": "File Manager",
"nav.files": "Files",
@@ -1184,6 +1238,7 @@
"nav.skip_to_content": "Skip to main content",
"nav.status": "Status",
"nav.subscription": "Subscription",
"nav.system_reset": "System Reset",
"nav.toggle_dark_mode": "Toggle dark mode",
"nav.toggle_nav": "Toggle navigation menu",
"nav.upload": "Upload",
@@ -1451,6 +1506,20 @@
"profile.theme_system": "System Default",
"profile.update_password": "Update Password",
"profile.updating": "Updating…",
"qr_login.claimed_device": "Device: {device_name}",
"qr_login.claimed_message": "QR code login successful! Your mobile device is now connected.",
"qr_login.description": "Scan this QR code with the DocuElevate mobile app to log in instantly.",
"qr_login.expired_message": "This QR code has expired. Please generate a new one.",
"qr_login.generate_new": "Generate New QR Code",
"qr_login.heading": "Mobile App QR Login",
"qr_login.how_it_works": "How it works",
"qr_login.page_title": "QR Code Login DocuElevate",
"qr_login.pending_message": "Waiting for mobile app to scan…",
"qr_login.step_1": "Open the DocuElevate app on your phone",
"qr_login.step_2": "Tap \"Scan QR Code\" on the login screen",
"qr_login.step_3": "Point your camera at this QR code",
"qr_login.subtitle": "Log in to the mobile app by scanning a QR code from this page.",
"qr_login.time_remaining": "Expires in {seconds} seconds",
"queue.active_tasks": "Active Tasks",
"queue.auto_refresh_1": "Auto-refreshes every",
"queue.auto_refresh_2": "seconds",
@@ -1512,6 +1581,25 @@
"search.saved_label": "Saved Searches",
"search.saved_loading": "Loading...",
"search.title": "Search Documents",
"sessions.active_sessions": "Active Sessions",
"sessions.confirm_revoke_all": "This will log you out of all other devices and browsers, and revoke all API tokens. Continue?",
"sessions.confirm_revoke_one": "Are you sure you want to end this session?",
"sessions.current_session": "This device",
"sessions.device_info": "Device",
"sessions.expires": "Expires",
"sessions.ip_address": "IP Address",
"sessions.last_active": "Last active",
"sessions.log_off_everywhere": "Log Off All Other Sessions",
"sessions.log_off_everywhere_desc": "End all other browser sessions and revoke all API tokens. Your current session will remain active.",
"sessions.no_other_sessions": "No other active sessions found.",
"sessions.qr_login_link": "Log in on mobile via QR code",
"sessions.revoke": "End Session",
"sessions.revoked_all_success": "All other sessions have been ended.",
"sessions.revoked_success": "Session ended successfully.",
"sessions.security_heading": "Security & Sessions",
"sessions.security_subtitle": "Manage your active sessions across devices and browsers.",
"sessions.session_lifetime": "Session lifetime: {days} days",
"sessions.started": "Started",
"settings.audit_log_btn": "Audit Log",
"settings.autocomplete_hint": "Type to search known values, or enter any custom value.",
"settings.autocomplete_no_matches": "No matches — you can still type a custom value",
@@ -1723,6 +1811,36 @@
"subscription.upgrade_info": "Upgrades take effect immediately. Downgrades are scheduled for the end of your current billing period.",
"subscription.upgrade_to_prefix": "Upgrade to",
"subscription.usage_heading": "Usage",
"system_reset.danger_desc": "The actions below will permanently destroy data. They cannot be undone. Application settings and configuration are preserved, but all documents, files, processing history, and audit logs will be deleted.",
"system_reset.danger_zone": "Danger Zone — Irreversible Actions",
"system_reset.full_reset_button": "Wipe All Data",
"system_reset.full_reset_desc": "Permanently deletes all user data from the database and removes all work files from disk. The application will be in its initial state after this operation.",
"system_reset.full_reset_item_cache": "All watch-folder caches and ingestion state",
"system_reset.full_reset_item_db": "All document records, processing logs, and audit history",
"system_reset.full_reset_item_files": "All original, processed, and temporary files on disk",
"system_reset.full_reset_item_settings_kept": "Application settings and configuration are preserved",
"system_reset.full_reset_subtitle": "Wipe everything and start fresh",
"system_reset.full_reset_title": "Full System Reset",
"system_reset.heading": "System Reset",
"system_reset.js_error_generic": "An error occurred. Please check the server logs for details.",
"system_reset.js_success_full": "System reset complete. All user data has been wiped.",
"system_reset.js_success_reimport": "Reset complete. Original files have been staged for re-import via the watch folder.",
"system_reset.page_title": "System Reset",
"system_reset.reimport_button": "Reset & Re-import",
"system_reset.reimport_desc": "Copies your original files to a special reimport folder, wipes everything, then lets the watch-folder mechanism re-process them as if they were freshly uploaded.",
"system_reset.reimport_note": "Re-imported files will go through the full processing pipeline with the same rate limits and backoff strategy as regular uploads.",
"system_reset.reimport_step_1": "Original files are copied to a dedicated reimport folder",
"system_reset.reimport_step_2": "All data (database + work files) is wiped clean",
"system_reset.reimport_step_3": "The reimport folder is configured as a watch folder for automatic re-ingestion",
"system_reset.reimport_subtitle": "Wipe and re-process all original files",
"system_reset.reimport_title": "Reset & Re-import",
"system_reset.startup_reset_active": "Factory Reset on Startup is ACTIVE",
"system_reset.startup_reset_desc": "FACTORY_RESET_ON_STARTUP is enabled. All user data is wiped every time the application starts.",
"system_reset.subtitle": "Reset DocuElevate to a clean, fresh state. All user data will be permanently deleted.",
"system_reset.type_delete": "Type DELETE to confirm",
"system_reset.type_delete_help": "You must type the word DELETE in capital letters to enable the reset button.",
"system_reset.type_reimport": "Type REIMPORT to confirm",
"system_reset.type_reimport_help": "You must type the word REIMPORT in capital letters to enable the button.",
"terms.cookie_link": "Cookie Policy",
"terms.heading": "Terms of Service",
"terms.last_updated": "Last Updated:",