mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 00:01:10 +02:00
refactor(auth-ui): SessionManager i18n, shared userAgent util, a11y fixes
- 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>
This commit is contained in:
parent
3fb1eddc04
commit
3b7b6c9761
7 changed files with 126 additions and 67 deletions
|
|
@ -1,4 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { formatUserAgent } from '../utils/userAgent';
|
||||
|
||||
interface SecurityEvent {
|
||||
id: string;
|
||||
eventType: string;
|
||||
|
|
@ -139,30 +141,6 @@
|
|||
return `${dateFormatted}, ${timeStr}`;
|
||||
}
|
||||
}
|
||||
|
||||
function parseUserAgent(ua: string | null): string {
|
||||
if (!ua) return '';
|
||||
|
||||
let browser = '';
|
||||
let os = '';
|
||||
|
||||
// Detect browser
|
||||
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';
|
||||
|
||||
// Detect OS
|
||||
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';
|
||||
|
||||
const parts = [browser, os].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' · ') : '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="audit-log" style:--primary-color={primaryColor}>
|
||||
|
|
@ -233,9 +211,9 @@
|
|||
<span>{event.ipAddress}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if parseUserAgent(event.userAgent)}
|
||||
{#if formatUserAgent(event.userAgent)}
|
||||
<div class="event-device">
|
||||
{parseUserAgent(event.userAgent)}
|
||||
{formatUserAgent(event.userAgent)}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -784,4 +784,13 @@
|
|||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.pm-spinner {
|
||||
animation: none;
|
||||
}
|
||||
* {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -203,6 +203,15 @@
|
|||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.session-expired-banner {
|
||||
animation: none;
|
||||
}
|
||||
* {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Mobile: stack vertically */
|
||||
@media (max-width: 480px) {
|
||||
.session-expired-content {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { parseUserAgent, getDeviceType } from '../utils/userAgent';
|
||||
|
||||
export interface SessionManagerTranslations {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
|
|
@ -10,6 +12,14 @@
|
|||
confirmRevokeAll?: string;
|
||||
noSessions?: string;
|
||||
unknown?: string;
|
||||
refresh?: string;
|
||||
revokeError?: string;
|
||||
revokeAllError?: string;
|
||||
justNow?: string;
|
||||
minutesAgo?: string;
|
||||
hoursAgo?: string;
|
||||
yesterday?: string;
|
||||
daysAgo?: string;
|
||||
}
|
||||
|
||||
interface Session {
|
||||
|
|
@ -30,10 +40,11 @@
|
|||
onRefresh: () => Promise<void>;
|
||||
loading?: boolean;
|
||||
primaryColor?: string;
|
||||
locale?: 'de' | 'en';
|
||||
translations?: SessionManagerTranslations;
|
||||
}
|
||||
|
||||
const defaultTranslations: Required<SessionManagerTranslations> = {
|
||||
const defaultTranslationsDE: Required<SessionManagerTranslations> = {
|
||||
title: 'Aktive Sitzungen',
|
||||
subtitle: 'Geräte, die aktuell angemeldet sind',
|
||||
current: 'Aktuell',
|
||||
|
|
@ -44,6 +55,35 @@
|
|||
confirmRevokeAll: 'Alle anderen Sitzungen wirklich beenden?',
|
||||
noSessions: 'Keine aktiven Sitzungen gefunden.',
|
||||
unknown: 'Unbekanntes Gerät',
|
||||
refresh: 'Aktualisieren',
|
||||
revokeError: 'Fehler beim Beenden der Sitzung',
|
||||
revokeAllError: 'Fehler beim Beenden der Sitzungen',
|
||||
justNow: 'gerade eben',
|
||||
minutesAgo: 'Min',
|
||||
hoursAgo: 'Std',
|
||||
yesterday: 'Gestern',
|
||||
daysAgo: 'Tagen',
|
||||
};
|
||||
|
||||
const defaultTranslationsEN: Required<SessionManagerTranslations> = {
|
||||
title: 'Active Sessions',
|
||||
subtitle: 'Devices currently signed in',
|
||||
current: 'Current',
|
||||
revoke: 'Revoke',
|
||||
revokeAll: 'Revoke all other sessions',
|
||||
lastActivity: 'Last activity',
|
||||
confirmRevoke: 'Really revoke this session?',
|
||||
confirmRevokeAll: 'Really revoke all other sessions?',
|
||||
noSessions: 'No active sessions found.',
|
||||
unknown: 'Unknown device',
|
||||
refresh: 'Refresh',
|
||||
revokeError: 'Error revoking session',
|
||||
revokeAllError: 'Error revoking sessions',
|
||||
justNow: 'just now',
|
||||
minutesAgo: 'min',
|
||||
hoursAgo: 'hrs',
|
||||
yesterday: 'Yesterday',
|
||||
daysAgo: 'days',
|
||||
};
|
||||
|
||||
let {
|
||||
|
|
@ -53,43 +93,16 @@
|
|||
onRefresh,
|
||||
loading = false,
|
||||
primaryColor = '#6366f1',
|
||||
locale = 'de',
|
||||
translations,
|
||||
}: Props = $props();
|
||||
|
||||
let t = $derived({ ...defaultTranslations, ...translations });
|
||||
const defaults = $derived(locale === 'en' ? defaultTranslationsEN : defaultTranslationsDE);
|
||||
let t = $derived({ ...defaults, ...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);
|
||||
|
|
@ -99,14 +112,15 @@
|
|||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHours = Math.floor(diffMin / 60);
|
||||
const diffDays = Math.floor(diffHours / 24);
|
||||
const dateLocale = locale === 'en' ? 'en-US' : 'de-DE';
|
||||
|
||||
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`;
|
||||
if (diffSec < 60) return t.justNow!;
|
||||
if (diffMin < 60) return `${diffMin} ${t.minutesAgo}`;
|
||||
if (diffHours < 24) return `${diffHours} ${t.hoursAgo}`;
|
||||
if (diffDays === 1) return t.yesterday!;
|
||||
if (diffDays < 7) return `${diffDays} ${t.daysAgo}`;
|
||||
|
||||
return date.toLocaleDateString('de-DE', {
|
||||
return date.toLocaleDateString(dateLocale, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
|
|
@ -132,12 +146,12 @@
|
|||
try {
|
||||
const result = await onRevoke(sessionId);
|
||||
if (!result.success) {
|
||||
error = result.error || 'Fehler beim Beenden der Sitzung';
|
||||
error = result.error || t.revokeError!;
|
||||
} else {
|
||||
await onRefresh();
|
||||
}
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Beenden der Sitzung';
|
||||
error = e instanceof Error ? e.message : t.revokeError!;
|
||||
} finally {
|
||||
revoking = null;
|
||||
}
|
||||
|
|
@ -154,7 +168,7 @@
|
|||
}
|
||||
await onRefresh();
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Beenden der Sitzungen';
|
||||
error = e instanceof Error ? e.message : t.revokeAllError!;
|
||||
} finally {
|
||||
revokingAll = false;
|
||||
}
|
||||
|
|
@ -188,7 +202,7 @@
|
|||
class="refresh-button"
|
||||
onclick={onRefresh}
|
||||
disabled={loading}
|
||||
aria-label="Aktualisieren"
|
||||
aria-label={t.refresh}
|
||||
>
|
||||
<svg
|
||||
class="refresh-icon"
|
||||
|
|
|
|||
|
|
@ -653,4 +653,10 @@
|
|||
text-align: center;
|
||||
color: hsl(var(--theme-foreground, 220 9% 10%));
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ export {
|
|||
resetGuestWelcome,
|
||||
resetAllGuestWelcome,
|
||||
} from './utils/guestWelcome';
|
||||
export { parseUserAgent, getDeviceType, formatUserAgent } from './utils/userAgent';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
|
|
|
|||
42
packages/shared-auth-ui/src/utils/userAgent.ts
Normal file
42
packages/shared-auth-ui/src/utils/userAgent.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
/**
|
||||
* Parse a user agent string to extract browser and OS information.
|
||||
*/
|
||||
export 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 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the device type from a user agent string.
|
||||
*/
|
||||
export 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';
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a user agent string as a display label (e.g. "Chrome · macOS").
|
||||
*/
|
||||
export function formatUserAgent(ua: string | null): string {
|
||||
const { browser, os } = parseUserAgent(ua);
|
||||
const parts = [browser, os].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' \u00b7 ') : '';
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue