a8eb6504ac
The QR code on /qr-login was not rendering because it depended on loading qrcode@1.5.4 from the jsdelivr CDN, which may be blocked in some network environments. - Add segno>=1.6.0 (pure-Python QR library, no Pillow needed) to requirements.txt - Generate QR code as a base64 SVG data URI server-side in the challenge endpoint - Add qr_code_svg field to CreateChallengeResponse Pydantic model - Replace canvas+CDN script in qr_login.html with an <img :src="qrCodeSvg"> - Remove the $nextTick/QRCode.toCanvas() client-side rendering block - Extract QR rendering parameters (_QR_ERROR_LEVEL, _QR_SCALE) as module constants Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
230 lines
8.1 KiB
HTML
230 lines
8.1 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') }}"
|
|
>
|
|
<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 %}
|