From 3b54d4d48e5bb5f5530abf2db9b8285b669ab82c Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 31 Mar 2026 13:41:53 +0200 Subject: [PATCH] refactor(auth-ui): i18n, security fixes, type safety across auth components - Add locale prop (de/en) to PasswordStrength, ChangePassword, SecurityOnboarding, AuditLog, AuthGate tier screen - Add 13 new i18n keys to LoginTranslations for 2FA, lockout, magic link - Fix date formatting to use locale in AuditLog - Rewrite ForgotPasswordPage to Tailwind (matching Login/Register) - Fix HTML injection in ForgotPasswordPage (remove @html with email) - Guard DEV credentials behind isDevMode check in LoginPage - Extend AuthResult type with twoFactorRedirect and retryAfter - Remove as any casts in LoginPage - Replace scoped CSS with Tailwind in AuthGate tier-denied screen Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/components/AuditLog.svelte | 139 +++++-- .../src/components/AuthGate.svelte | 163 +++----- .../src/components/ChangePassword.svelte | 63 ++- .../src/components/PasswordStrength.svelte | 7 +- .../src/components/SecurityOnboarding.svelte | 64 ++- .../src/pages/ForgotPasswordPage.svelte | 375 ++++++++++-------- .../shared-auth-ui/src/pages/LoginPage.svelte | 67 +++- packages/shared-auth/src/types/index.ts | 2 + 8 files changed, 517 insertions(+), 363 deletions(-) diff --git a/packages/shared-auth-ui/src/components/AuditLog.svelte b/packages/shared-auth-ui/src/components/AuditLog.svelte index 385fede92..34c267e7e 100644 --- a/packages/shared-auth-ui/src/components/AuditLog.svelte +++ b/packages/shared-auth-ui/src/components/AuditLog.svelte @@ -13,9 +13,16 @@ onRefresh: () => Promise; loading?: boolean; primaryColor?: string; + locale?: 'de' | 'en'; } - let { events, onRefresh, loading = false, primaryColor = '#6366f1' }: Props = $props(); + let { + events, + onRefresh, + loading = false, + primaryColor = '#6366f1', + locale = 'de', + }: Props = $props(); interface EventInfo { label: string; @@ -23,41 +30,88 @@ badgeText: string; } + const eventLabelsDE: Record = { + 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 = { + 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 = { + 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 { - switch (eventType) { - case 'login_success': - return { label: 'Anmeldung erfolgreich', badgeClass: 'badge-success', badgeText: '' }; - case 'login_failure': - return { label: 'Anmeldung fehlgeschlagen', badgeClass: 'badge-danger', badgeText: '' }; - case 'register': - return { label: 'Konto erstellt', badgeClass: 'badge-info', badgeText: 'Neu' }; - case 'logout': - return { label: 'Abgemeldet', badgeClass: 'badge-neutral', badgeText: '' }; - case 'password_changed': - return { label: 'Passwort geändert', badgeClass: 'badge-warning', badgeText: '' }; - case 'password_reset_requested': - return { label: 'Passwort-Reset angefordert', badgeClass: 'badge-warning', badgeText: '' }; - case 'password_reset_completed': - return { label: 'Passwort zurückgesetzt', badgeClass: 'badge-warning', badgeText: '' }; - case 'passkey_registered': - return { label: 'Passkey registriert', badgeClass: 'badge-warning', badgeText: '' }; - case 'passkey_login_success': - return { label: 'Passkey-Anmeldung', badgeClass: 'badge-success', badgeText: '' }; - case 'passkey_deleted': - return { label: 'Passkey gelöscht', badgeClass: 'badge-danger', badgeText: '' }; - case 'two_factor_enabled': - return { label: '2FA aktiviert', badgeClass: 'badge-success', badgeText: '' }; - case 'two_factor_disabled': - return { label: '2FA deaktiviert', badgeClass: 'badge-warning', badgeText: '' }; - case 'account_locked': - return { label: 'Konto gesperrt', badgeClass: 'badge-danger', badgeText: '' }; - case 'account_deleted': - return { label: 'Konto gelöscht', badgeClass: 'badge-danger', badgeText: '' }; - case 'sso_token_exchange': - return { label: 'SSO-Anmeldung', badgeClass: 'badge-success', badgeText: '' }; - default: - return { label: eventType, badgeClass: 'badge-neutral', badgeText: '' }; + 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 { @@ -65,18 +119,19 @@ 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('de-DE', { + const timeStr = date.toLocaleTimeString(dateLocale, { hour: '2-digit', minute: '2-digit', }); if (diffDays === 0) { - return `Heute, ${timeStr}`; + return `${txt.today}, ${timeStr}`; } else if (diffDays === 1) { - return `Gestern, ${timeStr}`; + return `${txt.yesterday}, ${timeStr}`; } else { - const dateFormatted = date.toLocaleDateString('de-DE', { + const dateFormatted = date.toLocaleDateString(dateLocale, { day: '2-digit', month: '2-digit', year: 'numeric', @@ -124,8 +179,8 @@
-

Sicherheitsprotokoll

-

Letzte Aktivitäten deines Kontos

+

{txt.title}

+

{txt.subtitle}

- + {:else if !ready} @@ -138,97 +171,3 @@ {:else} {@render children()} {/if} - - diff --git a/packages/shared-auth-ui/src/components/ChangePassword.svelte b/packages/shared-auth-ui/src/components/ChangePassword.svelte index e216fd8d5..517e02ef7 100644 --- a/packages/shared-auth-ui/src/components/ChangePassword.svelte +++ b/packages/shared-auth-ui/src/components/ChangePassword.svelte @@ -8,9 +8,42 @@ newPassword: string ) => Promise<{ success: boolean; error?: string }>; primaryColor?: string; + locale?: 'de' | 'en'; } - let { onChangePassword, primaryColor = '#6366f1' }: Props = $props(); + let { onChangePassword, primaryColor = '#6366f1', locale = 'de' }: Props = $props(); + + const textsDE = { + title: 'Passwort ändern', + successMessage: 'Passwort erfolgreich geändert.', + currentPasswordLabel: 'Aktuelles Passwort', + newPasswordLabel: 'Neues Passwort', + confirmPasswordLabel: 'Neues Passwort bestätigen', + hidePassword: 'Passwort verbergen', + showPassword: 'Passwort anzeigen', + minChars: 'Mindestens 8 Zeichen', + passwordsMismatch: 'Passwörter stimmen nicht überein', + submitting: 'Wird geändert...', + submit: 'Passwort ändern', + defaultError: 'Fehler beim Ändern des Passworts', + }; + + const textsEN = { + title: 'Change Password', + successMessage: 'Password changed successfully.', + currentPasswordLabel: 'Current Password', + newPasswordLabel: 'New Password', + confirmPasswordLabel: 'Confirm New Password', + hidePassword: 'Hide password', + showPassword: 'Show password', + minChars: 'At least 8 characters', + passwordsMismatch: 'Passwords do not match', + submitting: 'Changing...', + submit: 'Change Password', + defaultError: 'Error changing password', + }; + + const txt = $derived(locale === 'en' ? textsEN : textsDE); let currentPassword = $state(''); let newPassword = $state(''); @@ -53,17 +86,17 @@ reset(); setTimeout(() => (success = false), 4000); } else { - error = result.error || 'Fehler beim Ändern des Passworts'; + error = result.error || txt.defaultError; } }
-

Passwort ändern

+

{txt.title}

{#if success}
-

Passwort erfolgreich geändert.

+

{txt.successMessage}

{/if} @@ -80,7 +113,7 @@ }} >
- +
(showCurrentPassword = !showCurrentPassword)} class="password-toggle" - aria-label={showCurrentPassword ? 'Passwort verbergen' : 'Passwort anzeigen'} + aria-label={showCurrentPassword ? txt.hidePassword : txt.showPassword} > {#if showCurrentPassword} @@ -107,7 +140,7 @@
- +
(showNewPassword = !showNewPassword)} class="password-toggle" - aria-label={showNewPassword ? 'Passwort verbergen' : 'Passwort anzeigen'} + aria-label={showNewPassword ? txt.hidePassword : txt.showPassword} > {#if showNewPassword} @@ -133,16 +166,16 @@
{#if newPassword.length > 0 && !passwordLongEnough} -

Mindestens 8 Zeichen

+

{txt.minChars}

{:else} -

Mindestens 8 Zeichen

+

{txt.minChars}

{/if}
- +
- +
(showConfirmPassword = !showConfirmPassword)} class="password-toggle" - aria-label={showConfirmPassword ? 'Passwort verbergen' : 'Passwort anzeigen'} + aria-label={showConfirmPassword ? txt.hidePassword : txt.showPassword} > {#if showConfirmPassword} @@ -168,7 +201,7 @@
{#if confirmPassword.length > 0 && !passwordsMatch} -

Passwörter stimmen nicht überein

+

{txt.passwordsMismatch}

{/if}
@@ -179,7 +212,7 @@ style:background-color={primaryColor + '60'} style:border-color={primaryColor} > - {loading ? 'Wird geändert...' : 'Passwort ändern'} + {loading ? txt.submitting : txt.submit}
diff --git a/packages/shared-auth-ui/src/components/PasswordStrength.svelte b/packages/shared-auth-ui/src/components/PasswordStrength.svelte index 7b28d948c..ad0f5e2cf 100644 --- a/packages/shared-auth-ui/src/components/PasswordStrength.svelte +++ b/packages/shared-auth-ui/src/components/PasswordStrength.svelte @@ -5,9 +5,10 @@ interface Props { password: string; primaryColor?: string; + locale?: 'de' | 'en'; } - let { password, primaryColor = '#6366f1' }: Props = $props(); + let { password, primaryColor = '#6366f1', locale = 'de' }: Props = $props(); let score = $state(0); let feedback = $state(''); @@ -29,7 +30,9 @@ initialized = true; } - const labels = ['Sehr schwach', 'Schwach', 'OK', 'Stark', 'Sehr stark']; + const labelsDE = ['Sehr schwach', 'Schwach', 'OK', 'Stark', 'Sehr stark']; + const labelsEN = ['Very weak', 'Weak', 'OK', 'Strong', 'Very strong']; + const labels = $derived(locale === 'en' ? labelsEN : labelsDE); const colors = ['#ef4444', '#f97316', '#eab308', '#84cc16', '#22c55e']; $effect(() => { diff --git a/packages/shared-auth-ui/src/components/SecurityOnboarding.svelte b/packages/shared-auth-ui/src/components/SecurityOnboarding.svelte index ccabdf52b..731df2b20 100644 --- a/packages/shared-auth-ui/src/components/SecurityOnboarding.svelte +++ b/packages/shared-auth-ui/src/components/SecurityOnboarding.svelte @@ -4,9 +4,48 @@ onSkip: () => void; passkeyAvailable: boolean; primaryColor?: string; + locale?: 'de' | 'en'; } - let { onSetupPasskey, onSkip, passkeyAvailable, primaryColor = '#6366f1' }: Props = $props(); + let { + onSetupPasskey, + onSkip, + passkeyAvailable, + primaryColor = '#6366f1', + locale = 'de', + }: Props = $props(); + + const textsDE = { + passkeySetup: 'Passkey eingerichtet!', + passkeySetupDescription: + 'Dein Konto ist jetzt mit einem Passkey gesichert. Du kannst dich ab sofort ohne Passwort anmelden.', + continue: 'Weiter', + secureYourAccount: 'Sichere dein Konto', + secureDescription: 'Schütze dein Konto mit zusätzlicher Sicherheit.', + setupPasskey: 'Passkey einrichten', + passkeyDescription: 'Anmelden ohne Passwort mit Touch ID, Face ID oder Windows Hello', + setupNow: 'Jetzt einrichten', + hint2fa: 'Du kannst 2FA jederzeit in den Einstellungen aktivieren.', + skip: 'Überspringen', + defaultError: 'Fehler beim Einrichten des Passkeys', + }; + + const textsEN = { + passkeySetup: 'Passkey set up!', + passkeySetupDescription: + 'Your account is now secured with a passkey. You can sign in without a password from now on.', + continue: 'Continue', + secureYourAccount: 'Secure your account', + secureDescription: 'Protect your account with additional security.', + setupPasskey: 'Set up Passkey', + passkeyDescription: 'Sign in without a password using Touch ID, Face ID, or Windows Hello', + setupNow: 'Set up now', + hint2fa: 'You can enable 2FA at any time in Settings.', + skip: 'Skip', + defaultError: 'Error setting up passkey', + }; + + const txt = $derived(locale === 'en' ? textsEN : textsDE); let loading = $state(false); let error = $state(null); @@ -23,7 +62,7 @@ if (result.success) { success = true; } else { - error = result.error || 'Fehler beim Einrichten des Passkeys'; + error = result.error || txt.defaultError; } } @@ -45,10 +84,9 @@
-

Passkey eingerichtet!

+

{txt.passkeySetup}

- Dein Konto ist jetzt mit einem Passkey gesichert. Du kannst dich ab sofort ohne Passwort - anmelden. + {txt.passkeySetupDescription}

{:else} @@ -77,8 +115,8 @@ -

Sichere dein Konto

-

Schütze dein Konto mit zusätzlicher Sicherheit.

+

{txt.secureYourAccount}

+

{txt.secureDescription}

{#if error}
-

Passkey einrichten

+

{txt.setupPasskey}

- Anmelden ohne Passwort mit Touch ID, Face ID oder Windows Hello + {txt.passkeyDescription}

{/if} -

Du kannst 2FA jederzeit in den Einstellungen aktivieren.

+

{txt.hint2fa}

- + {/if} diff --git a/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte b/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte index 9db522ddc..38b0cea5c 100644 --- a/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte +++ b/packages/shared-auth-ui/src/pages/ForgotPasswordPage.svelte @@ -121,10 +121,6 @@ } } - function getPageBackground() { - return isDark ? darkBackground : lightBackground; - } - async function handleForgotPassword() { loading = true; error = null; @@ -151,23 +147,23 @@ Forgot Password - {appName} + +
{#if headerControls} -
+
{@render headerControls()}
{/if} - -
-
- -
-

- {appName} -

-
- - -
-
- -

+ +
+
- {mode === 'form' ? t.titleForm : t.titleSuccess} -

+ +
+

{appName}

+
- - {#if error} -
-

{error}

-
- {/if} - - - {#if mode === 'form'} -
{ - e.preventDefault(); - handleForgotPassword(); - }} - class="pb-4" + +
+
+ +

-

- {t.description} -

+ {mode === 'form' ? t.titleForm : t.titleSuccess} +

-
- + + {#if error} + + {/if} - - - - -
- -
- - - {:else} -
-
-
- + {t.description} +

+ +
+
-

- {@html getSuccessMessage(resetEmail).replace( - resetEmail, - `${resetEmail}` - )} -

-
- -
+ + + +
+ - -
-
- {/if} -
-
- - {#if appSlider} -
- {@render appSlider()} + + {:else} +
+
+
+ +
+ +

+ {getSuccessMessage(resetEmail)} +

+
+ +
+ + + +
+
+ {/if} +
- {:else} - -
+ + + {#if appSlider} +
+ {@render appSlider()} +
{/if}
+ + diff --git a/packages/shared-auth-ui/src/pages/LoginPage.svelte b/packages/shared-auth-ui/src/pages/LoginPage.svelte index 31d3afda0..1012cc8b5 100644 --- a/packages/shared-auth-ui/src/pages/LoginPage.svelte +++ b/packages/shared-auth-ui/src/pages/LoginPage.svelte @@ -29,6 +29,23 @@ resendVerification?: string; resendingVerification?: string; verificationEmailSent?: string; + twoFactorTitle?: string; + twoFactorSubtitle?: string; + twoFactorBackupSubtitle?: string; + twoFactorBackupPlaceholder?: string; + twoFactorTrustDevice?: string; + twoFactorVerifying?: string; + twoFactorConfirm?: string; + twoFactorUseAuthenticator?: string; + twoFactorUseBackupCode?: string; + twoFactorBackToLogin?: string; + accountLocked?: string; + tooManyAttempts?: string; + retryIn?: string; + resetPassword?: string; + magicLinkSent?: string; + magicLinkSending?: string; + magicLinkButton?: string; } const defaultTranslations: LoginTranslations = { @@ -57,6 +74,23 @@ resendVerification: 'Resend verification email', resendingVerification: 'Sending...', verificationEmailSent: 'Verification email sent! Please check your inbox.', + twoFactorTitle: 'Zwei-Faktor-Authentifizierung', + twoFactorSubtitle: 'Gib den Code aus deiner Authenticator-App ein', + twoFactorBackupSubtitle: 'Gib einen Backup-Code ein', + twoFactorBackupPlaceholder: 'Backup-Code', + twoFactorTrustDevice: 'Diesem Gerät 30 Tage vertrauen', + twoFactorVerifying: 'Prüfe...', + twoFactorConfirm: 'Bestätigen', + twoFactorUseAuthenticator: 'Authenticator-App verwenden', + twoFactorUseBackupCode: 'Backup-Code verwenden', + twoFactorBackToLogin: 'Zurück zum Login', + accountLocked: 'Konto vorübergehend gesperrt', + tooManyAttempts: 'Zu viele fehlgeschlagene Anmeldeversuche.', + retryIn: 'Erneut versuchen in', + resetPassword: 'Passwort zurücksetzen', + magicLinkSent: 'Login-Link an {email} gesendet!', + magicLinkSending: 'Wird gesendet...', + magicLinkButton: 'Login-Link per E-Mail senden', }; interface Props { @@ -462,15 +496,13 @@ class="text-xl font-semibold" style:color={isDark ? 'rgba(255,255,255,0.9)' : 'rgba(0,0,0,0.9)'} > - Zwei-Faktor-Authentifizierung + {t.twoFactorTitle}

- {useBackupCode - ? 'Gib einen Backup-Code ein' - : 'Gib den Code aus deiner Authenticator-App ein'} + {useBackupCode ? t.twoFactorBackupSubtitle : t.twoFactorSubtitle}

@@ -494,7 +526,7 @@ - Diesem Gerät 30 Tage vertrauen + {t.twoFactorTrustDevice} {/if} @@ -534,7 +566,7 @@ style:border-color={primaryColor} style:color={isDark ? '#fff' : '#000'} > - {loading ? 'Prüfe...' : 'Bestätigen'} + {loading ? t.twoFactorVerifying : t.twoFactorConfirm} @@ -548,7 +580,7 @@ clearError(); }} > - {useBackupCode ? 'Authenticator-App verwenden' : 'Backup-Code verwenden'} + {useBackupCode ? t.twoFactorUseAuthenticator : t.twoFactorUseBackupCode} {:else} {#if showVerifiedBanner} @@ -661,13 +693,11 @@
-

Konto vorübergehend gesperrt

+

{t.accountLocked}

- Zu viele fehlgeschlagene Anmeldeversuche. + {t.tooManyAttempts} {#if rateLimitCountdown > 0} - Erneut versuchen in {formatCountdown(rateLimitCountdown)} - {:else} - Du kannst es jetzt erneut versuchen. + {t.retryIn} {formatCountdown(rateLimitCountdown)} {/if}

@@ -703,7 +733,8 @@ {/if} {#if rateLimitCountdown > 0}

- Erneut versuchen in {formatCountdown(rateLimitCountdown)} + {t.retryIn} + {formatCountdown(rateLimitCountdown)}

{/if} @@ -846,7 +877,7 @@ aria-live="polite" > -

Login-Link an {email} gesendet!

+

{t.magicLinkSent?.replace('{email}', email)}

{/if} {/if} diff --git a/packages/shared-auth/src/types/index.ts b/packages/shared-auth/src/types/index.ts index 026951119..2a78e6a01 100644 --- a/packages/shared-auth/src/types/index.ts +++ b/packages/shared-auth/src/types/index.ts @@ -61,6 +61,8 @@ export interface AuthResult { success: boolean; error?: string; needsVerification?: boolean; + twoFactorRedirect?: boolean; + retryAfter?: number; } /**