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:
@@ -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 ──────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user