Files
gh-christianlouis-docuelevate/frontend/templates/qr_login.html
T
copilot-swe-agent[bot] a08b103271 fix: merge main into feature branch - resolve all merge conflicts cleanly
Merges origin/main (v0.156.0) into the classification feature branch,
properly resolving all 23 merge conflicts:

- Auto-generated files (BUILD_DATE, VERSION, etc.): accept main's version
- Non-classification files (SharePoint, QR auth, session mgmt, mobile): accept main's version
- Classification files (api/__init__.py, models.py, migrations/env.py, conftest.py):
  keep classification additions alongside main's content

Previously the branch was incorrectly removing files from main (SharePoint
integration, QR scanner, session management). This merge properly preserves
all main branch content while maintaining the classification feature additions.

Migration chain validated: 038_add_classification_rules chains from
037_add_user_sessions_and_qr_challenges.

Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
2026-03-17 16:14:45 +00:00

235 lines
8.4 KiB
HTML

{% 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') }}"
>
<canvas id="qr-canvas" width="256" height="256"></canvas>
</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 library (lightweight, no external deps) -->
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
<script>
function qrLoginPage() {
return {
status: 'loading', // loading | pending | claimed | expired | error
challengeId: null,
challengeToken: '',
qrPayload: '',
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.expiresAt = new Date(data.expires_at);
this._ttlSeconds = data.ttl_seconds || 120;
this._receivedAt = Date.now();
this.status = 'pending';
this.deviceName = '';
// Render QR code
this.$nextTick(() => {
const canvas = document.getElementById('qr-canvas');
if (canvas && typeof QRCode !== 'undefined') {
QRCode.toCanvas(canvas, this.qrPayload, {
width: 256,
margin: 2,
color: { dark: '#000000', light: '#ffffff' },
});
}
});
// 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 %}