0f6a1ee1ec
The QR login page countdown timer compared the server's UTC expiration timestamp against the client's local clock, causing the QR code to appear immediately expired when the client clock was ahead of the server. Changes: - Add ttl_seconds field to CreateChallengeResponse (seconds until expiry) - Frontend countdown now uses relative elapsed time since response was received, eliminating clock-skew issues - Mobile app: replace alert-only QR button with actual camera-based QR code scanner using expo-camera - Add QRScannerScreen with barcode scanning, permission handling, and scan area overlay - Update camera permission description to mention QR code scanning - Add tests for ttl_seconds computation Co-authored-by: christianlouis <361235+christianlouis@users.noreply.github.com>
235 lines
8.4 KiB
HTML
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 %}
|