mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-17 13:29:39 +02:00
- Add locale prop (de/en) to SessionManager with full English translations - Extract duplicated parseUserAgent/getDeviceType to utils/userAgent.ts - Fix hardcoded aria-label in SessionManager refresh button - Add prefers-reduced-motion to PasskeyManager, TwoFactorSetup, SessionExpiredBanner Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
431 lines
10 KiB
Svelte
431 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { formatUserAgent } from '../utils/userAgent';
|
|
|
|
interface SecurityEvent {
|
|
id: string;
|
|
eventType: string;
|
|
ipAddress: string | null;
|
|
userAgent: string | null;
|
|
metadata: any;
|
|
createdAt: string;
|
|
}
|
|
|
|
interface Props {
|
|
events: SecurityEvent[];
|
|
onRefresh: () => Promise<void>;
|
|
loading?: boolean;
|
|
primaryColor?: string;
|
|
locale?: 'de' | 'en';
|
|
}
|
|
|
|
let {
|
|
events,
|
|
onRefresh,
|
|
loading = false,
|
|
primaryColor = '#6366f1',
|
|
locale = 'de',
|
|
}: Props = $props();
|
|
|
|
interface EventInfo {
|
|
label: string;
|
|
badgeClass: string;
|
|
badgeText: string;
|
|
}
|
|
|
|
const eventLabelsDE: Record<string, { label: string; badgeText: string }> = {
|
|
login_success: { label: 'Anmeldung erfolgreich', badgeText: '' },
|
|
login_failure: { label: 'Anmeldung fehlgeschlagen', badgeText: '' },
|
|
register: { label: 'Konto erstellt', badgeText: 'Neu' },
|
|
logout: { label: 'Abgemeldet', badgeText: '' },
|
|
password_changed: { label: 'Passwort geändert', badgeText: '' },
|
|
password_reset_requested: { label: 'Passwort-Reset angefordert', badgeText: '' },
|
|
password_reset_completed: { label: 'Passwort zurückgesetzt', badgeText: '' },
|
|
passkey_registered: { label: 'Passkey registriert', badgeText: '' },
|
|
passkey_login_success: { label: 'Passkey-Anmeldung', badgeText: '' },
|
|
passkey_deleted: { label: 'Passkey gelöscht', badgeText: '' },
|
|
two_factor_enabled: { label: '2FA aktiviert', badgeText: '' },
|
|
two_factor_disabled: { label: '2FA deaktiviert', badgeText: '' },
|
|
account_locked: { label: 'Konto gesperrt', badgeText: '' },
|
|
account_deleted: { label: 'Konto gelöscht', badgeText: '' },
|
|
sso_token_exchange: { label: 'SSO-Anmeldung', badgeText: '' },
|
|
};
|
|
|
|
const eventLabelsEN: Record<string, { label: string; badgeText: string }> = {
|
|
login_success: { label: 'Login successful', badgeText: '' },
|
|
login_failure: { label: 'Login failed', badgeText: '' },
|
|
register: { label: 'Account created', badgeText: 'New' },
|
|
logout: { label: 'Logged out', badgeText: '' },
|
|
password_changed: { label: 'Password changed', badgeText: '' },
|
|
password_reset_requested: { label: 'Password reset requested', badgeText: '' },
|
|
password_reset_completed: { label: 'Password reset', badgeText: '' },
|
|
passkey_registered: { label: 'Passkey registered', badgeText: '' },
|
|
passkey_login_success: { label: 'Passkey login', badgeText: '' },
|
|
passkey_deleted: { label: 'Passkey deleted', badgeText: '' },
|
|
two_factor_enabled: { label: '2FA enabled', badgeText: '' },
|
|
two_factor_disabled: { label: '2FA disabled', badgeText: '' },
|
|
account_locked: { label: 'Account locked', badgeText: '' },
|
|
account_deleted: { label: 'Account deleted', badgeText: '' },
|
|
sso_token_exchange: { label: 'SSO login', badgeText: '' },
|
|
};
|
|
|
|
const badgeClasses: Record<string, string> = {
|
|
login_success: 'badge-success',
|
|
login_failure: 'badge-danger',
|
|
register: 'badge-info',
|
|
logout: 'badge-neutral',
|
|
password_changed: 'badge-warning',
|
|
password_reset_requested: 'badge-warning',
|
|
password_reset_completed: 'badge-warning',
|
|
passkey_registered: 'badge-warning',
|
|
passkey_login_success: 'badge-success',
|
|
passkey_deleted: 'badge-danger',
|
|
two_factor_enabled: 'badge-success',
|
|
two_factor_disabled: 'badge-warning',
|
|
account_locked: 'badge-danger',
|
|
account_deleted: 'badge-danger',
|
|
sso_token_exchange: 'badge-success',
|
|
};
|
|
|
|
const auditTextsDE = {
|
|
title: 'Sicherheitsprotokoll',
|
|
subtitle: 'Letzte Aktivitäten deines Kontos',
|
|
refresh: 'Aktualisieren',
|
|
empty: 'Keine Sicherheitsereignisse vorhanden.',
|
|
today: 'Heute',
|
|
yesterday: 'Gestern',
|
|
};
|
|
|
|
const auditTextsEN = {
|
|
title: 'Security Log',
|
|
subtitle: 'Recent activity on your account',
|
|
refresh: 'Refresh',
|
|
empty: 'No security events found.',
|
|
today: 'Today',
|
|
yesterday: 'Yesterday',
|
|
};
|
|
|
|
const txt = $derived(locale === 'en' ? auditTextsEN : auditTextsDE);
|
|
|
|
function getEventInfo(eventType: string): EventInfo {
|
|
const labels = locale === 'en' ? eventLabelsEN : eventLabelsDE;
|
|
const info = labels[eventType];
|
|
const badgeClass = badgeClasses[eventType] || 'badge-neutral';
|
|
if (info) {
|
|
return { label: info.label, badgeClass, badgeText: info.badgeText };
|
|
}
|
|
return { label: eventType, badgeClass: 'badge-neutral', badgeText: '' };
|
|
}
|
|
|
|
function formatDate(dateStr: string): string {
|
|
const date = new Date(dateStr);
|
|
const now = new Date();
|
|
const diffMs = now.getTime() - date.getTime();
|
|
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
|
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
|
|
|
|
const timeStr = date.toLocaleTimeString(dateLocale, {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
});
|
|
|
|
if (diffDays === 0) {
|
|
return `${txt.today}, ${timeStr}`;
|
|
} else if (diffDays === 1) {
|
|
return `${txt.yesterday}, ${timeStr}`;
|
|
} else {
|
|
const dateFormatted = date.toLocaleDateString(dateLocale, {
|
|
day: '2-digit',
|
|
month: '2-digit',
|
|
year: 'numeric',
|
|
});
|
|
return `${dateFormatted}, ${timeStr}`;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="audit-log" style:--primary-color={primaryColor}>
|
|
<div class="audit-header">
|
|
<div class="audit-header-left">
|
|
<div class="audit-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 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
|
/>
|
|
</svg>
|
|
</div>
|
|
<div>
|
|
<h3 class="audit-title">{txt.title}</h3>
|
|
<p class="audit-subtitle">{txt.subtitle}</p>
|
|
</div>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
class="refresh-button"
|
|
onclick={onRefresh}
|
|
disabled={loading}
|
|
aria-label={txt.refresh}
|
|
>
|
|
<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 loading && events.length === 0}
|
|
<div class="loading-state">
|
|
<div class="loading-spinner"></div>
|
|
</div>
|
|
{:else if events.length === 0}
|
|
<p class="empty-state">{txt.empty}</p>
|
|
{:else}
|
|
<div class="event-list">
|
|
{#each events as event (event.id)}
|
|
{@const info = getEventInfo(event.eventType)}
|
|
<div class="event-item">
|
|
<div class="event-badge {info.badgeClass}"></div>
|
|
<div class="event-content">
|
|
<div class="event-label">
|
|
{info.label}
|
|
{#if info.badgeText}
|
|
<span class="event-tag">{info.badgeText}</span>
|
|
{/if}
|
|
</div>
|
|
<div class="event-meta">
|
|
<span>{formatDate(event.createdAt)}</span>
|
|
{#if event.ipAddress}
|
|
<span class="meta-separator">·</span>
|
|
<span>{event.ipAddress}</span>
|
|
{/if}
|
|
</div>
|
|
{#if formatUserAgent(event.userAgent)}
|
|
<div class="event-device">
|
|
{formatUserAgent(event.userAgent)}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style>
|
|
.audit-log {
|
|
width: 100%;
|
|
}
|
|
|
|
.audit-header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1.5rem;
|
|
}
|
|
|
|
.audit-header-left {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.audit-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);
|
|
}
|
|
|
|
.audit-icon .icon {
|
|
height: 1.25rem;
|
|
width: 1.25rem;
|
|
}
|
|
|
|
.audit-title {
|
|
font-size: 1.125rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
|
|
.audit-subtitle {
|
|
font-size: 0.875rem;
|
|
color: hsl(var(--theme-muted-foreground, 220 9% 46%));
|
|
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 hsl(var(--theme-border, 220 13% 91%));
|
|
background: transparent;
|
|
color: hsl(var(--theme-muted-foreground, 220 9% 46%));
|
|
cursor: pointer;
|
|
transition: all 0.2s;
|
|
}
|
|
|
|
.refresh-button:hover:not(:disabled) {
|
|
background: hsl(var(--theme-muted, 220 14% 96%));
|
|
color: hsl(var(--theme-foreground, 220 9% 10%));
|
|
}
|
|
|
|
.refresh-button:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.refresh-icon {
|
|
width: 1.125rem;
|
|
height: 1.125rem;
|
|
}
|
|
|
|
.spinning {
|
|
animation: spin 1s linear infinite;
|
|
}
|
|
|
|
@keyframes spin {
|
|
to {
|
|
transform: rotate(360deg);
|
|
}
|
|
}
|
|
|
|
.loading-state {
|
|
display: flex;
|
|
justify-content: center;
|
|
padding: 2rem;
|
|
}
|
|
|
|
.loading-spinner {
|
|
width: 2rem;
|
|
height: 2rem;
|
|
border: 3px solid hsl(var(--theme-border, 220 13% 91%));
|
|
border-top-color: var(--primary-color);
|
|
border-radius: 50%;
|
|
animation: spin 0.8s linear infinite;
|
|
}
|
|
|
|
.empty-state {
|
|
text-align: center;
|
|
padding: 2rem;
|
|
color: hsl(var(--theme-muted-foreground, 220 9% 46%));
|
|
font-size: 0.875rem;
|
|
}
|
|
|
|
.event-list {
|
|
display: flex;
|
|
flex-direction: column;
|
|
max-height: 28rem;
|
|
overflow-y: auto;
|
|
border: 1px solid hsl(var(--theme-border, 220 13% 91%));
|
|
border-radius: 0.75rem;
|
|
}
|
|
|
|
.event-item {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 0.75rem;
|
|
padding: 0.875rem 1rem;
|
|
border-bottom: 1px solid hsl(var(--theme-border, 220 13% 91%));
|
|
}
|
|
|
|
.event-item:last-child {
|
|
border-bottom: none;
|
|
}
|
|
|
|
/* Semantic status colors kept */
|
|
.event-badge {
|
|
flex-shrink: 0;
|
|
width: 0.625rem;
|
|
height: 0.625rem;
|
|
border-radius: 50%;
|
|
margin-top: 0.375rem;
|
|
}
|
|
|
|
.badge-success {
|
|
background-color: #22c55e;
|
|
}
|
|
|
|
.badge-danger {
|
|
background-color: #ef4444;
|
|
}
|
|
|
|
.badge-warning {
|
|
background-color: #f59e0b;
|
|
}
|
|
|
|
.badge-info {
|
|
background-color: #3b82f6;
|
|
}
|
|
|
|
.badge-neutral {
|
|
background-color: #9ca3af;
|
|
}
|
|
|
|
.event-content {
|
|
flex: 1;
|
|
min-width: 0;
|
|
}
|
|
|
|
.event-label {
|
|
font-size: 0.875rem;
|
|
font-weight: 500;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.event-tag {
|
|
font-size: 0.625rem;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.05em;
|
|
padding: 0.0625rem 0.375rem;
|
|
border-radius: 9999px;
|
|
background-color: color-mix(in srgb, var(--primary-color) 15%, transparent);
|
|
color: var(--primary-color);
|
|
}
|
|
|
|
.event-meta {
|
|
font-size: 0.75rem;
|
|
color: hsl(var(--theme-muted-foreground, 220 9% 46%));
|
|
margin-top: 0.125rem;
|
|
}
|
|
|
|
.meta-separator {
|
|
margin: 0 0.25rem;
|
|
}
|
|
|
|
.event-device {
|
|
font-size: 0.75rem;
|
|
color: hsl(var(--theme-muted-foreground, 220 9% 46%));
|
|
opacity: 0.8;
|
|
}
|
|
|
|
@media (prefers-reduced-motion: reduce) {
|
|
.spinning {
|
|
animation: none;
|
|
}
|
|
.loading-spinner {
|
|
animation: none;
|
|
}
|
|
}
|
|
</style>
|