feat(auth): add session management UI, QR login page, mobile QR support, translations
Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
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') }}"
|
||||
>
|
||||
<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 %}
|
||||
Reference in New Issue
Block a user