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:
@@ -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 %}
|
||||
Reference in New Issue
Block a user