mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 12:59:39 +02:00
feat(auth): session management UI and improved account lockout feedback
Session management: - GET /auth/sessions and DELETE /auth/sessions/:id endpoints - listSessions() and revokeSession() in shared-auth client - SessionManager component: active sessions list with device info, "Aktuell" badge, revoke individual or all other sessions - Integrated in ManaCore settings page Account lockout UX: - Dedicated amber lockout banner (distinct from generic rate-limit) - "Konto vorübergehend gesperrt" with MM:SS countdown - "Passwort zurücksetzen" link as alternative action - formatCountdown helper for clean time display Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2624e5a6b7
commit
8f56feb115
8 changed files with 898 additions and 4 deletions
|
|
@ -213,6 +213,18 @@ export const authStore = {
|
|||
return authService.getSecurityEvents();
|
||||
},
|
||||
|
||||
async listSessions() {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return [];
|
||||
return authService.listSessions();
|
||||
},
|
||||
|
||||
async revokeSession(sessionId: string) {
|
||||
const authService = getAuthService();
|
||||
if (!authService) return { success: false, error: 'Auth not available' };
|
||||
return authService.revokeSession(sessionId);
|
||||
},
|
||||
|
||||
/**
|
||||
* Sign in with email and password
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { Button, Input, Card, PageHeader, GlobalSettingsSection } from '@manacore/shared-ui';
|
||||
import { PasskeyManager, TwoFactorSetup, AuditLog } from '@manacore/shared-auth-ui';
|
||||
import {
|
||||
PasskeyManager,
|
||||
TwoFactorSetup,
|
||||
AuditLog,
|
||||
SessionManager,
|
||||
} from '@manacore/shared-auth-ui';
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { creditsService } from '$lib/api/credits';
|
||||
import type { CreditBalance } from '$lib/api/credits';
|
||||
|
|
@ -27,6 +32,10 @@
|
|||
let securityEvents = $state<any[]>([]);
|
||||
let securityEventsLoading = $state(false);
|
||||
|
||||
// Sessions
|
||||
let sessions = $state<any[]>([]);
|
||||
let sessionsLoading = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
if (authStore.isAuthenticated) {
|
||||
try {
|
||||
|
|
@ -38,6 +47,10 @@
|
|||
securityEventsLoading = true;
|
||||
securityEvents = await authStore.getSecurityEvents();
|
||||
securityEventsLoading = false;
|
||||
// Load sessions
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessionsLoading = false;
|
||||
} catch (e) {
|
||||
console.error('Failed to load data:', e);
|
||||
}
|
||||
|
|
@ -302,6 +315,23 @@
|
|||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Sessions Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
<SessionManager
|
||||
{sessions}
|
||||
loading={sessionsLoading}
|
||||
onRevoke={(id) => authStore.revokeSession(id)}
|
||||
onRefresh={async () => {
|
||||
sessionsLoading = true;
|
||||
sessions = await authStore.listSessions();
|
||||
sessionsLoading = false;
|
||||
}}
|
||||
primaryColor="#6366f1"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- Two-Factor Authentication Section -->
|
||||
<Card>
|
||||
<div class="p-6">
|
||||
|
|
|
|||
631
packages/shared-auth-ui/src/components/SessionManager.svelte
Normal file
631
packages/shared-auth-ui/src/components/SessionManager.svelte
Normal file
|
|
@ -0,0 +1,631 @@
|
|||
<script lang="ts">
|
||||
export interface SessionManagerTranslations {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
current?: string;
|
||||
revoke?: string;
|
||||
revokeAll?: string;
|
||||
lastActivity?: string;
|
||||
confirmRevoke?: string;
|
||||
confirmRevokeAll?: string;
|
||||
noSessions?: string;
|
||||
unknown?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
id: string;
|
||||
ipAddress: string | null;
|
||||
userAgent: string | null;
|
||||
deviceId: string | null;
|
||||
deviceName: string | null;
|
||||
lastActivityAt: string | null;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
sessions: Session[];
|
||||
currentSessionId?: string;
|
||||
onRevoke: (sessionId: string) => Promise<{ success: boolean; error?: string }>;
|
||||
onRefresh: () => Promise<void>;
|
||||
loading?: boolean;
|
||||
primaryColor?: string;
|
||||
translations?: SessionManagerTranslations;
|
||||
}
|
||||
|
||||
const defaultTranslations: Required<SessionManagerTranslations> = {
|
||||
title: 'Aktive Sitzungen',
|
||||
subtitle: 'Geräte, die aktuell angemeldet sind',
|
||||
current: 'Aktuell',
|
||||
revoke: 'Abmelden',
|
||||
revokeAll: 'Alle anderen Sitzungen beenden',
|
||||
lastActivity: 'Letzte Aktivität',
|
||||
confirmRevoke: 'Sitzung wirklich beenden?',
|
||||
confirmRevokeAll: 'Alle anderen Sitzungen wirklich beenden?',
|
||||
noSessions: 'Keine aktiven Sitzungen gefunden.',
|
||||
unknown: 'Unbekanntes Gerät',
|
||||
};
|
||||
|
||||
let {
|
||||
sessions,
|
||||
currentSessionId,
|
||||
onRevoke,
|
||||
onRefresh,
|
||||
loading = false,
|
||||
primaryColor = '#6366f1',
|
||||
translations,
|
||||
}: Props = $props();
|
||||
|
||||
let t = $derived({ ...defaultTranslations, ...translations });
|
||||
let revoking = $state<string | null>(null);
|
||||
let revokingAll = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
function parseUserAgent(ua: string | null): { browser: string; os: string } {
|
||||
if (!ua) return { browser: '', os: '' };
|
||||
|
||||
let browser = '';
|
||||
let os = '';
|
||||
|
||||
if (ua.includes('Firefox/')) browser = 'Firefox';
|
||||
else if (ua.includes('Edg/')) browser = 'Edge';
|
||||
else if (ua.includes('Chrome/') && !ua.includes('Edg/')) browser = 'Chrome';
|
||||
else if (ua.includes('Safari/') && !ua.includes('Chrome/')) browser = 'Safari';
|
||||
else if (ua.includes('Opera/') || ua.includes('OPR/')) browser = 'Opera';
|
||||
|
||||
if (ua.includes('Windows')) os = 'Windows';
|
||||
else if (ua.includes('Mac OS X') || ua.includes('Macintosh')) os = 'macOS';
|
||||
else if (ua.includes('Linux') && !ua.includes('Android')) os = 'Linux';
|
||||
else if (ua.includes('Android')) os = 'Android';
|
||||
else if (ua.includes('iPhone') || ua.includes('iPad')) os = 'iOS';
|
||||
|
||||
return { browser, os };
|
||||
}
|
||||
|
||||
function getDeviceType(ua: string | null): 'mobile' | 'desktop' | 'tablet' {
|
||||
if (!ua) return 'desktop';
|
||||
if (ua.includes('iPhone') || (ua.includes('Android') && !ua.includes('Tablet')))
|
||||
return 'mobile';
|
||||
if (ua.includes('iPad') || ua.includes('Tablet')) return 'tablet';
|
||||
return 'desktop';
|
||||
}
|
||||
|
||||
function formatRelativeTime(dateStr: string | null): string {
|
||||
if (!dateStr) return '';
|
||||
const date = new Date(dateStr);
|
||||
const now = new Date();
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
|
||||
if (diffSec < 60) return 'gerade eben';
|
||||
if (diffMin < 60) return `vor ${diffMin} Min`;
|
||||
if (diffHours < 24) return `vor ${diffHours} Std`;
|
||||
if (diffDays === 1) return 'Gestern';
|
||||
if (diffDays < 7) return `vor ${diffDays} Tagen`;
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
function getSessionLabel(session: Session): string {
|
||||
if (session.deviceName) return session.deviceName;
|
||||
const { browser, os } = parseUserAgent(session.userAgent);
|
||||
const parts = [browser, os].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' \u00b7 ') : t.unknown;
|
||||
}
|
||||
|
||||
function isCurrent(session: Session): boolean {
|
||||
if (!currentSessionId) return false;
|
||||
return session.id === currentSessionId;
|
||||
}
|
||||
|
||||
async function handleRevoke(sessionId: string) {
|
||||
if (!confirm(t.confirmRevoke)) return;
|
||||
error = null;
|
||||
revoking = sessionId;
|
||||
try {
|
||||
const result = await onRevoke(sessionId);
|
||||
if (!result.success) {
|
||||
error = result.error || 'Fehler beim Beenden der Sitzung';
|
||||
} else {
|
||||
await onRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Beenden der Sitzung';
|
||||
} finally {
|
||||
revoking = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevokeAll() {
|
||||
if (!confirm(t.confirmRevokeAll)) return;
|
||||
error = null;
|
||||
revokingAll = true;
|
||||
try {
|
||||
const otherSessions = sessions.filter((s) => !isCurrent(s));
|
||||
for (const session of otherSessions) {
|
||||
await onRevoke(session.id);
|
||||
}
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Beenden der Sitzungen';
|
||||
} finally {
|
||||
revokingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
let otherSessionCount = $derived(
|
||||
currentSessionId ? sessions.filter((s) => !isCurrent(s)).length : sessions.length - 1
|
||||
);
|
||||
</script>
|
||||
|
||||
<div class="session-manager" style:--primary-color={primaryColor}>
|
||||
<div class="session-header">
|
||||
<div class="session-header-left">
|
||||
<div class="session-icon">
|
||||
<svg class="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="session-title">{t.title} ({sessions.length})</h3>
|
||||
<p class="session-subtitle">{t.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="refresh-button"
|
||||
onclick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label="Aktualisieren"
|
||||
>
|
||||
<svg
|
||||
class="refresh-icon"
|
||||
class:spinning={loading}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="2"
|
||||
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="error-message">{error}</div>
|
||||
{/if}
|
||||
|
||||
{#if loading && sessions.length === 0}
|
||||
<div class="loading-state">
|
||||
<div class="loading-spinner"></div>
|
||||
</div>
|
||||
{:else if sessions.length === 0}
|
||||
<p class="empty-state">{t.noSessions}</p>
|
||||
{:else}
|
||||
<div class="session-list">
|
||||
{#each sessions as session (session.id)}
|
||||
{@const current = isCurrent(session)}
|
||||
{@const deviceType = getDeviceType(session.userAgent)}
|
||||
<div class="session-item" class:session-current={current}>
|
||||
<div class="session-device-icon">
|
||||
{#if deviceType === 'mobile'}
|
||||
<svg class="device-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 18h.01M8 21h8a2 2 0 002-2V5a2 2 0 00-2-2H8a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else if deviceType === 'tablet'}
|
||||
<svg class="device-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M12 18h.01M7 21h10a2 2 0 002-2V5a2 2 0 00-2-2H7a2 2 0 00-2 2v14a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{:else}
|
||||
<svg class="device-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
stroke-width="1.5"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
||||
/>
|
||||
</svg>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="session-content">
|
||||
<div class="session-label">
|
||||
<span class="session-name">{getSessionLabel(session)}</span>
|
||||
{#if current}
|
||||
<span class="current-badge">{t.current}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if session.ipAddress}
|
||||
<div class="session-ip">{session.ipAddress}</div>
|
||||
{/if}
|
||||
{#if session.lastActivityAt}
|
||||
<div class="session-activity">
|
||||
{t.lastActivity}: {formatRelativeTime(session.lastActivityAt)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if !current}
|
||||
<button
|
||||
type="button"
|
||||
class="revoke-button"
|
||||
onclick={() => handleRevoke(session.id)}
|
||||
disabled={revoking === session.id || revokingAll}
|
||||
>
|
||||
{#if revoking === session.id}
|
||||
<span class="revoke-spinner"></span>
|
||||
{:else}
|
||||
{t.revoke}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if otherSessionCount > 0}
|
||||
<button
|
||||
type="button"
|
||||
class="revoke-all-button"
|
||||
onclick={handleRevokeAll}
|
||||
disabled={revokingAll}
|
||||
>
|
||||
{#if revokingAll}
|
||||
<span class="revoke-spinner"></span>
|
||||
{:else}
|
||||
{t.revokeAll}
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.session-manager {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.session-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.session-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.session-icon {
|
||||
display: flex;
|
||||
height: 2.5rem;
|
||||
width: 2.5rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 9999px;
|
||||
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.session-icon .icon {
|
||||
height: 1.25rem;
|
||||
width: 1.25rem;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.session-subtitle {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
background: transparent;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.refresh-button:hover:not(:disabled) {
|
||||
background: var(--hover-bg, #f3f4f6);
|
||||
color: var(--text-primary, #111827);
|
||||
}
|
||||
|
||||
.refresh-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
:global(.dark) .refresh-button {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .refresh-button:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.refresh-icon {
|
||||
width: 1.125rem;
|
||||
height: 1.125rem;
|
||||
}
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: 0.75rem 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
background-color: #fef2f2;
|
||||
color: #dc2626;
|
||||
border: 1px solid #fecaca;
|
||||
}
|
||||
|
||||
:global(.dark) .error-message {
|
||||
background-color: rgba(220, 38, 38, 0.1);
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
.loading-state {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
border: 3px solid var(--border-color, #e5e7eb);
|
||||
border-top-color: var(--primary-color);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 2rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 0.75rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:global(.dark) .session-list {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.session-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
:global(.dark) .session-item {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
|
||||
.session-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.session-current {
|
||||
background-color: color-mix(in srgb, var(--primary-color) 5%, transparent);
|
||||
}
|
||||
|
||||
.session-device-icon {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.25rem;
|
||||
height: 2.25rem;
|
||||
border-radius: 0.5rem;
|
||||
background-color: var(--hover-bg, #f3f4f6);
|
||||
color: var(--text-muted, #6b7280);
|
||||
}
|
||||
|
||||
:global(.dark) .session-device-icon {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.device-icon {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
}
|
||||
|
||||
.session-content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.session-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.current-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 9999px;
|
||||
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.session-ip {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.session-activity {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted, #9ca3af);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.revoke-button {
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
padding: 0.375rem 0.75rem;
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.375rem;
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
background: transparent;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
min-width: 5rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.revoke-button:hover:not(:disabled) {
|
||||
background-color: #fef2f2;
|
||||
border-color: #fecaca;
|
||||
}
|
||||
|
||||
:global(.dark) .revoke-button {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
:global(.dark) .revoke-button:hover:not(:disabled) {
|
||||
background-color: rgba(220, 38, 38, 0.1);
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
}
|
||||
|
||||
.revoke-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.revoke-all-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
margin-top: 0.75rem;
|
||||
padding: 0.625rem 1rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
border-radius: 0.5rem;
|
||||
border: 1px solid #fecaca;
|
||||
background: #fef2f2;
|
||||
color: #dc2626;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.revoke-all-button:hover:not(:disabled) {
|
||||
background-color: #fee2e2;
|
||||
border-color: #fca5a5;
|
||||
}
|
||||
|
||||
:global(.dark) .revoke-all-button {
|
||||
background-color: rgba(220, 38, 38, 0.1);
|
||||
border-color: rgba(220, 38, 38, 0.2);
|
||||
color: #fca5a5;
|
||||
}
|
||||
|
||||
:global(.dark) .revoke-all-button:hover:not(:disabled) {
|
||||
background-color: rgba(220, 38, 38, 0.15);
|
||||
}
|
||||
|
||||
.revoke-all-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.revoke-spinner {
|
||||
display: inline-block;
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
border: 2px solid currentColor;
|
||||
border-top-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinning {
|
||||
animation: none;
|
||||
}
|
||||
.loading-spinner {
|
||||
animation: none;
|
||||
}
|
||||
.revoke-spinner {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -14,6 +14,7 @@ export { default as SecurityOnboarding } from './components/SecurityOnboarding.s
|
|||
export { default as ChangePassword } from './components/ChangePassword.svelte';
|
||||
export { default as PasswordStrength } from './components/PasswordStrength.svelte';
|
||||
export { default as AuditLog } from './components/AuditLog.svelte';
|
||||
export { default as SessionManager } from './components/SessionManager.svelte';
|
||||
|
||||
// Utilities
|
||||
export {
|
||||
|
|
@ -34,3 +35,4 @@ export type {
|
|||
} from './types';
|
||||
export type { PasskeyManagerTranslations } from './components/PasskeyManager.svelte';
|
||||
export type { TwoFactorSetupTranslations } from './components/TwoFactorSetup.svelte';
|
||||
export type { SessionManagerTranslations } from './components/SessionManager.svelte';
|
||||
|
|
|
|||
|
|
@ -148,6 +148,7 @@
|
|||
let useBackupCode = $state(false);
|
||||
let trustDevice = $state(false);
|
||||
let rateLimitCountdown = $state(0);
|
||||
let isLockedOut = $state(false);
|
||||
let magicLinkSent = $state(false);
|
||||
let sendingMagicLink = $state(false);
|
||||
|
||||
|
|
@ -157,9 +158,17 @@
|
|||
rateLimitCountdown--;
|
||||
}, 1000);
|
||||
return () => clearTimeout(timer);
|
||||
} else if (isLockedOut) {
|
||||
isLockedOut = false;
|
||||
}
|
||||
});
|
||||
|
||||
function formatCountdown(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return m > 0 ? `${m}:${s.toString().padStart(2, '0')}` : `${s}s`;
|
||||
}
|
||||
|
||||
// Theme state - can be toggled manually, defaults to system preference
|
||||
let userThemePreference = $state<'light' | 'dark' | null>(null);
|
||||
let systemIsDark = $state(
|
||||
|
|
@ -267,13 +276,14 @@
|
|||
} else {
|
||||
setError(result.error || t.signInFailed, 'general');
|
||||
|
||||
// Detect rate limiting
|
||||
// Detect rate limiting vs account lockout
|
||||
if (result.error?.includes('Too Many') || result.error?.includes('rate limit')) {
|
||||
rateLimitCountdown = 60; // 1 minute cooldown
|
||||
} else if (
|
||||
result.error?.includes('temporarily locked') ||
|
||||
result.error === 'ACCOUNT_LOCKED'
|
||||
) {
|
||||
isLockedOut = true;
|
||||
rateLimitCountdown = (result as any).retryAfter || 300; // 5 min default
|
||||
}
|
||||
}
|
||||
|
|
@ -596,7 +606,32 @@
|
|||
</div>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
{#if isLockedOut}
|
||||
<div class="lockout-banner" role="alert" aria-live="assertive">
|
||||
<div class="lockout-icon">
|
||||
<Warning size={24} />
|
||||
</div>
|
||||
<div class="lockout-content">
|
||||
<p class="lockout-title">Konto vorübergehend gesperrt</p>
|
||||
<p class="lockout-text">
|
||||
Zu viele fehlgeschlagene Anmeldeversuche.
|
||||
{#if rateLimitCountdown > 0}
|
||||
Erneut versuchen in <strong>{formatCountdown(rateLimitCountdown)}</strong>
|
||||
{:else}
|
||||
Du kannst es jetzt erneut versuchen.
|
||||
{/if}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="lockout-reset-link"
|
||||
onclick={() => goto(forgotPasswordPath)}
|
||||
style:color={primaryColor}
|
||||
>
|
||||
Passwort zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else if error}
|
||||
<div class="error-message" id="form-error" role="alert" aria-live="assertive">
|
||||
<Warning size={18} class="text-red-500 shrink-0" />
|
||||
<div class="error-content">
|
||||
|
|
@ -613,7 +648,9 @@
|
|||
</button>
|
||||
{/if}
|
||||
{#if rateLimitCountdown > 0}
|
||||
<p class="retry-countdown">Erneut versuchen in {rateLimitCountdown}s</p>
|
||||
<p class="retry-countdown">
|
||||
Erneut versuchen in {formatCountdown(rateLimitCountdown)}
|
||||
</p>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1013,6 +1050,50 @@
|
|||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.lockout-banner {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
border-radius: 0.75rem;
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
border: 1px solid rgba(245, 158, 11, 0.3);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
.lockout-icon {
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
|
||||
.lockout-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.lockout-title {
|
||||
font-weight: 600;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.lockout-text {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.lockout-reset-link {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
text-align: left;
|
||||
text-decoration: underline;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.resend-link {
|
||||
background: none;
|
||||
border: none;
|
||||
|
|
|
|||
|
|
@ -824,6 +824,52 @@ export function createAuthService(config: AuthServiceConfig) {
|
|||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* List active sessions
|
||||
*/
|
||||
async listSessions(): Promise<any[]> {
|
||||
try {
|
||||
const appToken = await service.getAppToken();
|
||||
if (!appToken) return [];
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/auth/sessions`, {
|
||||
headers: { Authorization: `Bearer ${appToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) return [];
|
||||
return await res.json();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Revoke a session
|
||||
*/
|
||||
async revokeSession(sessionId: string): Promise<AuthResult> {
|
||||
try {
|
||||
const appToken = await service.getAppToken();
|
||||
if (!appToken) return { success: false, error: 'Not authenticated' };
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/v1/auth/sessions/${sessionId}`, {
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${appToken}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
return { success: false, error: err.message || 'Failed to revoke session' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Failed to revoke session',
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Get the current app token
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -837,6 +837,47 @@ export class AuthController {
|
|||
return this.betterAuthService.getSecurityEvents(user.userId);
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Session Management
|
||||
// =========================================================================
|
||||
|
||||
/**
|
||||
* List active sessions for the current user
|
||||
*/
|
||||
@Get('sessions')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@ApiOperation({ summary: 'List active sessions' })
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiResponse({ status: 200, description: 'Returns list of active sessions' })
|
||||
@ApiResponse({ status: 401, description: 'Not authenticated' })
|
||||
async listSessions(@CurrentUser() user: CurrentUserData) {
|
||||
return this.betterAuthService.listSessions(user.userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific session
|
||||
*/
|
||||
@Delete('sessions/:id')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@HttpCode(HttpStatus.NO_CONTENT)
|
||||
@ApiOperation({ summary: 'Revoke a session' })
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@ApiResponse({ status: 204, description: 'Session revoked successfully' })
|
||||
@ApiResponse({ status: 401, description: 'Not authenticated' })
|
||||
@ApiResponse({ status: 404, description: 'Session not found' })
|
||||
async revokeSession(
|
||||
@CurrentUser() user: CurrentUserData,
|
||||
@Param('id') sessionId: string,
|
||||
@Req() req: Request
|
||||
) {
|
||||
await this.betterAuthService.revokeSession(user.userId, sessionId);
|
||||
this.securityEvents.logEventWithRequest(req, {
|
||||
userId: user.userId,
|
||||
eventType: SecurityEventType.LOGOUT,
|
||||
metadata: { revokedSessionId: sessionId },
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Passkey (WebAuthn) Endpoints
|
||||
// =========================================================================
|
||||
|
|
|
|||
|
|
@ -2189,4 +2189,55 @@ export class BetterAuthService {
|
|||
|
||||
return events;
|
||||
}
|
||||
|
||||
/**
|
||||
* List active sessions for a user
|
||||
*/
|
||||
async listSessions(userId: string) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { sessions } = await import('../../db/schema');
|
||||
const { eq, and, isNull, gt } = await import('drizzle-orm');
|
||||
|
||||
const activeSessions = await db
|
||||
.select({
|
||||
id: sessions.id,
|
||||
ipAddress: sessions.ipAddress,
|
||||
userAgent: sessions.userAgent,
|
||||
deviceId: sessions.deviceId,
|
||||
deviceName: sessions.deviceName,
|
||||
lastActivityAt: sessions.lastActivityAt,
|
||||
createdAt: sessions.createdAt,
|
||||
expiresAt: sessions.expiresAt,
|
||||
})
|
||||
.from(sessions)
|
||||
.where(
|
||||
and(
|
||||
eq(sessions.userId, userId),
|
||||
isNull(sessions.revokedAt),
|
||||
gt(sessions.expiresAt, new Date())
|
||||
)
|
||||
)
|
||||
.orderBy(sessions.lastActivityAt);
|
||||
|
||||
return activeSessions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a specific session
|
||||
*/
|
||||
async revokeSession(userId: string, sessionId: string) {
|
||||
const db = getDb(this.databaseUrl);
|
||||
const { sessions } = await import('../../db/schema');
|
||||
const { eq, and } = await import('drizzle-orm');
|
||||
|
||||
const result = await db
|
||||
.update(sessions)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(sessions.id, sessionId), eq(sessions.userId, userId)))
|
||||
.returning({ id: sessions.id });
|
||||
|
||||
if (result.length === 0) {
|
||||
throw new NotFoundException('Session not found');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue