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:
copilot-swe-agent[bot]
2026-03-16 22:38:13 +00:00
parent a4588a57cb
commit 51821092f4
10 changed files with 699 additions and 8 deletions
+194
View File
@@ -343,9 +343,203 @@
</section>
</template>
<!-- ── Security & Sessions card ──────────────────────────────────────── -->
<section
class="bg-white dark:bg-gray-800 shadow rounded-lg p-6 mb-6"
aria-labelledby="security-heading"
x-data="sessionManager()"
x-init="loadSessions()"
>
<h2 id="security-heading" class="text-base font-semibold text-gray-900 dark:text-white mb-1">
<i class="fas fa-shield-alt text-gray-400 mr-2" aria-hidden="true"></i>{{ _("sessions.security_heading") }}
</h2>
<p class="text-sm text-gray-500 dark:text-gray-400 mb-4">
{{ _("sessions.security_subtitle") }}
</p>
<!-- Session lifetime info -->
<div class="text-xs text-gray-400 dark:text-gray-500 mb-4" x-show="lifetimeDays > 0">
<i class="fas fa-clock mr-1" aria-hidden="true"></i>
<span x-text="'{{ _("sessions.session_lifetime") }}'.replace('{days}', lifetimeDays)"></span>
</div>
<!-- Active sessions list -->
<div class="space-y-3 mb-5">
<template x-for="session in sessions" :key="session.id">
<div
class="flex items-center justify-between border border-gray-200 dark:border-gray-700 rounded-lg p-3"
:class="session.is_current ? 'bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700' : ''"
>
<div class="flex items-center gap-3 min-w-0">
<i
:class="session.device_info && session.device_info.includes('iPhone') ? 'fas fa-mobile-alt' :
session.device_info && session.device_info.includes('iPad') ? 'fas fa-tablet-alt' :
session.device_info && session.device_info.includes('Android') ? 'fas fa-mobile-alt' :
session.device_info && session.device_info.includes('App') ? 'fas fa-mobile-alt' :
'fas fa-desktop'"
class="text-gray-400 text-lg flex-shrink-0"
aria-hidden="true"
></i>
<div class="min-w-0">
<div class="text-sm font-medium text-gray-900 dark:text-white truncate">
<span x-text="session.device_info || 'Unknown Device'"></span>
<span
x-show="session.is_current"
class="ml-2 inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200"
>{{ _("sessions.current_session") }}</span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 space-x-3">
<span x-show="session.ip_address">
<i class="fas fa-globe mr-1" aria-hidden="true"></i><span x-text="session.ip_address"></span>
</span>
<span>
<i class="fas fa-clock mr-1" aria-hidden="true"></i>{{ _("sessions.last_active") }}
<span x-text="timeAgo(session.last_active_at)"></span>
</span>
</div>
</div>
</div>
<button
x-show="!session.is_current"
@click="revokeSession(session.id)"
class="flex-shrink-0 text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 text-sm font-medium px-3 py-1 rounded hover:bg-red-50 dark:hover:bg-red-900/20 transition"
style="min-height:44px; min-width:44px;"
:aria-label="'{{ _("sessions.revoke") }}'"
>
<i class="fas fa-sign-out-alt mr-1" aria-hidden="true"></i>{{ _("sessions.revoke") }}
</button>
</div>
</template>
<p
x-show="sessions.length <= 1"
class="text-sm text-gray-500 dark:text-gray-400 italic"
>{{ _("sessions.no_other_sessions") }}</p>
</div>
<!-- Log off everywhere + QR login row -->
<div class="flex flex-col sm:flex-row gap-3">
<button
@click="revokeAllSessions()"
class="inline-flex items-center justify-center px-4 py-2 border border-red-300 dark:border-red-700 rounded-lg text-sm font-medium text-red-700 dark:text-red-300 bg-white dark:bg-gray-800 hover:bg-red-50 dark:hover:bg-red-900/20 transition"
style="min-height:44px;"
:disabled="revoking"
>
<i class="fas fa-power-off mr-2" aria-hidden="true"></i>
<span x-text="revoking ? '{{ _("profile.saving") }}' : '{{ _("sessions.log_off_everywhere") }}'"></span>
</button>
<a
href="/qr-login"
class="inline-flex items-center justify-center px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-700 transition"
style="min-height:44px;"
>
<i class="fas fa-qrcode mr-2" aria-hidden="true"></i>
{{ _("sessions.qr_login_link") }}
</a>
</div>
<!-- Status banner for session actions -->
<div
x-show="sessionBanner.visible"
x-transition
class="mt-4 rounded-lg p-3 text-sm"
:class="sessionBanner.error
? 'bg-red-50 dark:bg-red-900/30 text-red-800 dark:text-red-200 border border-red-300 dark:border-red-700'
: 'bg-green-50 dark:bg-green-900/30 text-green-800 dark:text-green-200 border border-green-300 dark:border-green-700'"
role="alert"
aria-live="polite"
>
<span x-text="sessionBanner.message"></span>
</div>
</section>
</div><!-- /container -->
<script>
/* ── Session Manager Alpine component ──────────────────────────────────── */
function sessionManager() {
return {
sessions: [],
lifetimeDays: 0,
revoking: false,
sessionBanner: { visible: false, error: false, message: '' },
_csrfToken() {
return document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
},
async loadSessions() {
try {
const res = await fetch('/api/sessions/');
if (res.ok) {
const data = await res.json();
this.sessions = data.sessions || [];
this.lifetimeDays = data.session_lifetime_days || 30;
}
} catch (_e) { /* silently ignore */ }
},
async revokeSession(sessionId) {
if (!confirm({{ _("sessions.confirm_revoke_one") | tojson }})) return;
const csrf = this._csrfToken();
try {
const res = await fetch(`/api/sessions/${sessionId}`, {
method: 'DELETE',
headers: csrf ? { 'X-CSRF-Token': csrf } : {},
});
if (res.ok || res.status === 204) {
this.sessions = this.sessions.filter(s => s.id !== sessionId);
this._showSessionBanner({{ _("sessions.revoked_success") | tojson }}, false);
}
} catch (_e) {
this._showSessionBanner('Network error — please try again.', true);
}
},
async revokeAllSessions() {
if (!confirm({{ _("sessions.confirm_revoke_all") | tojson }})) return;
this.revoking = true;
const csrf = this._csrfToken();
try {
const res = await fetch('/api/sessions/revoke-all', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(csrf ? { 'X-CSRF-Token': csrf } : {}),
},
});
if (res.ok) {
await this.loadSessions();
this._showSessionBanner({{ _("sessions.revoked_all_success") | tojson }}, false);
}
} catch (_e) {
this._showSessionBanner('Network error — please try again.', true);
} finally {
this.revoking = false;
}
},
timeAgo(dateStr) {
if (!dateStr) return 'unknown';
const now = new Date();
const then = new Date(dateStr);
const diff = Math.floor((now - then) / 1000);
if (diff < 60) return 'just now';
if (diff < 3600) return Math.floor(diff / 60) + 'm ago';
if (diff < 86400) return Math.floor(diff / 3600) + 'h ago';
return Math.floor(diff / 86400) + 'd ago';
},
_showSessionBanner(msg, err) {
this.sessionBanner = { visible: true, error: err, message: msg };
if (!err) setTimeout(() => { this.sessionBanner.visible = false; }, 4000);
},
};
}
/* ── Profile Settings Alpine component ─────────────────────────────────── */
function profileSettings() {
return {
// ── State ──────────────────────────────────────────────────────────────