51821092f4
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
230 lines
8.2 KiB
HTML
230 lines
8.2 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,
|
|
|
|
_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.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.expiresAt) { this.countdown = 0; return; }
|
|
const remaining = Math.max(0, Math.floor((this.expiresAt - new Date()) / 1000));
|
|
this.countdown = remaining;
|
|
},
|
|
|
|
_stopTimers() {
|
|
if (this._pollTimer) { clearInterval(this._pollTimer); this._pollTimer = null; }
|
|
if (this._countdownTimer) { clearInterval(this._countdownTimer); this._countdownTimer = null; }
|
|
},
|
|
};
|
|
}
|
|
</script>
|
|
{% endblock %}
|