email = (string) auth()->user()->email; } public function updatePassword(): void { try { $validated = $this->validate([ 'current_password' => ['required', 'string', 'current_password'], 'password' => ['required', 'string', Password::defaults(), 'confirmed'], ]); } catch (ValidationException $e) { $this->reset('current_password', 'password', 'password_confirmation'); throw $e; } app(PasswordChanger::class)->change(Auth::user(), $validated['password'], session()->driver()); $this->reset('current_password', 'password', 'password_confirmation'); session()->flash('security-status', __('Passwort aktualisiert. Alle anderen angemeldeten Geräte wurden abgemeldet.')); } /** * Startet eine E-Mail-Änderung: Die neue Adresse wird nur als * `pending_email` vorgemerkt und erhält einen signierten Bestätigungslink. * Die alte Adresse bleibt bis zur Bestätigung aktiv — so sperrt sich * niemand mit einer vertippten Adresse aus dem Konto aus. */ public function updateEmail(): void { $validated = $this->validate([ 'email' => [ 'required', 'email', 'max:190', Rule::unique(User::class, 'email')->ignore(auth()->id()), ], ]); /** @var User $user */ $user = auth()->user(); $this->assertSecurityProof($user); if (strcasecmp($validated['email'], $user->email) === 0) { throw ValidationException::withMessages([ 'email' => __('Das ist bereits Ihre aktuelle E-Mail-Adresse.'), ]); } $user->forceFill(['pending_email' => $validated['email']])->save(); $expiresAt = now()->addHour(); $confirmUrl = URL::temporarySignedRoute('email-change.confirm', $expiresAt, [ 'user' => $user->id, 'hash' => sha1($validated['email']), ]); Mail::to($validated['email'])->send(new EmailChangeConfirmation( $user, $validated['email'], $confirmUrl, $expiresAt->format('H:i') )); session()->flash('security-status', __('Bestätigungslink an :email gesendet. Die Änderung wird erst nach Klick auf den Link wirksam — bis dahin bleibt Ihre bisherige Adresse aktiv.', ['email' => $validated['email']])); } public function cancelPendingEmailChange(): void { auth()->user()->forceFill(['pending_email' => null])->save(); session()->flash('security-status', __('Die ausstehende E-Mail-Änderung wurde verworfen.')); } /** * Erstmalige Einrichtung nur mit frischem Nachweis (Sicherheitsaudit * 07.10.2026, S-06): Wer nur eine fremde Session hat, soll keinen eigenen * Authenticator registrieren und den Inhaber damit aussperren können. */ public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void { /** @var User $user */ $user = auth()->user(); $this->assertSecurityProof($user); $enable($user); $this->showRecoveryCodes = false; session()->flash('security-status', __('Scannen Sie den QR-Code mit Ihrer Authenticator-App und bestätigen Sie die Einrichtung mit einem aktuellen Code.')); } /** * Bestätigt die 2FA-Einrichtung mit einem TOTP-Code. Erst danach ist * `two_factor_confirmed_at` gesetzt und der Login fordert die Challenge an. */ public function confirmTwoFactorAuthentication(ConfirmTwoFactorAuthentication $confirm): void { $this->validate(['twoFactorCode' => ['required', 'string']]); try { $confirm(auth()->user(), $this->twoFactorCode); } catch (ValidationException) { throw ValidationException::withMessages([ 'twoFactorCode' => __('Der eingegebene Code ist ungültig. Bitte versuchen Sie es mit einem aktuellen Code erneut.'), ]); } $this->reset('twoFactorCode'); $this->showRecoveryCodes = true; app(SecurityNotifier::class)->notify(auth()->user(), SecurityChange::TwoFactorEnabled); // Der gerade bestätigte Code belegt den zweiten Faktor auch für den // Admin-Bereich (Entscheidung 5). app(StrongAuthentication::class)->mark(auth()->user(), 'totp'); session()->flash('security-status', __('Zwei-Faktor-Authentifizierung ist jetzt aktiv. Speichern Sie Ihre Wiederherstellungs-Codes — sie werden nur einmal angezeigt.')); } public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void { /** @var User $user */ $user = auth()->user(); // Eine noch unbestätigte Einrichtung abzubrechen braucht keinen // Nachweis – aktiv ist die 2FA erst nach dem bestätigten App-Code. $wasEnabled = $user->hasEnabledTwoFactorAuthentication(); if ($wasEnabled) { $this->assertSecondFactor($user); } $disable($user); $this->reset('twoFactorCode'); $this->showRecoveryCodes = false; if ($wasEnabled) { app(SecurityNotifier::class)->notify($user, SecurityChange::TwoFactorDisabled); } session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.')); } public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void { /** @var User $user */ $user = auth()->user(); $this->assertSecondFactor($user); $generate($user); $this->showRecoveryCodes = true; session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt. Die alten Codes sind ab sofort ungültig.')); } /** * Startet das Hinzufügen eines Passkeys (Entscheidung 5, 07.10.2026): * erst der frische Identitätsnachweis, dann die Browser-Zeremonie gegen * Fortifys Registrierungsrouten (EnsurePasskeyManagementIsConfirmed prüft * den Nachweis dort erneut). */ public function startPasskeyRegistration(): void { $this->ensureNotImpersonating(); $validated = $this->validate(['passkeyName' => ['required', 'string', 'max:60']], [], [ 'passkeyName' => __('Name des Passkeys'), ]); $this->assertSecurityProof(auth()->user()); $this->dispatch('passkey-register', name: $validated['passkeyName']); } public function passkeyRegistered(): void { $this->reset('passkeyName'); session()->flash('security-status', __('Passkey hinzugefügt. Sie können sich ab sofort damit anmelden.')); } public function deletePasskey(int $passkeyId, DeletePasskey $delete): void { $this->ensureNotImpersonating(); /** @var User $user */ $user = auth()->user(); $this->assertSecurityProof($user); $passkey = $user->passkeys()->whereKey($passkeyId)->firstOrFail(); $delete($user, $passkey); session()->flash('security-status', __('Passkey entfernt.')); } /** * Beendet alle anderen Anmeldungen des Kontos, die aktuelle bleibt * (Sicherheitsaudit 07.10.2026, Phase 4). Nicht während einer * Impersonation — das träfe die Geräte des Kunden. */ public function logoutOtherDevices(): void { abort_if(app(UserImpersonation::class)->isActive(), 403, __('Während der Benutzer-Impersonation können keine Geräte abgemeldet werden.')); app(SessionRevocation::class)->logoutOtherDevices(Auth::user(), session()->driver()); session()->flash('security-status', __('Alle anderen Geräte wurden abgemeldet. Dieses Gerät bleibt angemeldet.')); } private function ensureNotImpersonating(): void { abort_if(app(UserImpersonation::class)->isActive(), 403, __('Während der Benutzer-Impersonation können keine Passkeys verwaltet werden.')); } public function with(): array { /** @var User $user */ $user = auth()->user(); $user->refresh(); $twoFactorPending = ! is_null($user->two_factor_secret ?? null) && is_null($user->two_factor_confirmed_at ?? null); $twoFactorConfirmed = ! is_null($user->two_factor_confirmed_at ?? null); $qrUrl = null; $recoveryCodes = []; if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) { try { // QR-Code nur während der Einrichtung; Recovery-Codes nur // direkt nach Bestätigung/Neu-Erzeugung (einmalige Anzeige). $qrUrl = $twoFactorPending ? $user->twoFactorQrCodeSvg() : null; $recoveryCodes = ($twoFactorConfirmed && $this->showRecoveryCodes) ? (json_decode(decrypt($user->two_factor_recovery_codes), true) ?: []) : []; } catch (\Throwable) { $qrUrl = null; $recoveryCodes = []; } } return [ 'user' => $user, 'twoFactorPending' => $twoFactorPending, 'twoFactorConfirmed' => $twoFactorConfirmed, 'twoFactorQrSvg' => $qrUrl, 'recoveryCodes' => $recoveryCodes, 'securityProofKind' => $this->securityProofKind($user), 'passkeys' => $user->passkeys()->latest()->get(['id', 'name', 'credential', 'last_used_at', 'created_at']), // Sessions liegen in Redis und lassen sich nicht je Konto aufzählen; // die Liste zeigt deshalb das aktuelle Gerät und die letzten // Anmeldungen aus dem Aktivitätsverlauf (ohne IP). 'currentSessionSince' => ($loggedInAtMs = (int) session(SessionRevocation::SESSION_KEY, 0)) > 0 ? \Carbon\Carbon::createFromTimestampMs($loggedInAtMs) : null, 'recentLogins' => ActivityEvent::query() ->where('user_id', $user->id) ->where('type', ActivityType::UserLoggedIn) ->orderByDesc('occurred_at') ->limit(5) ->get(['occurred_at', 'meta']), 'loginsLast30Days' => ActivityEvent::query() ->where('user_id', $user->id) ->where('type', ActivityType::UserLoggedIn) ->where('occurred_at', '>=', now()->subDays(30)) ->count(), ]; } }; ?>
{{-- ============== PAGE HEADER ============== --}} @if (session('security-status'))
{{ session('security-status') }}
@endif @if (session('security-error'))
{{ session('security-error') }}
@endif {{-- ============== KPI-Reihe ============== --}}
{{ __('E-Mail') }}
{{ $user->email }}
@if ($user->email_verified_at) {{ __('Bestätigt') }} @else {{ __('Nicht bestätigt') }} @endif
{{ __('Zwei-Faktor') }}
@if ($twoFactorConfirmed) {{ __('Aktiv') }} @elseif ($twoFactorPending) {{ __('Einrichtung ausstehend') }} @else {{ __('Nicht aktiv') }} @endif
@if ($twoFactorConfirmed) {{ __('Zusatzschutz aktiv') }} @elseif ($twoFactorPending) {{ __('Bestätigung erforderlich') }} @else {{ __('Empfohlen') }} @endif
{{ __('Letzter Login') }}
{{ $user->last_login_at?->format('d.m.Y H:i') ?? __('Unbekannt') }}
{{ $user->last_login_ip ?: __('Keine IP gespeichert') }}
{{ __('Anmeldungen') }}
{{ $loginsLast30Days }}
{{ __('In den letzten 30 Tagen') }}
{{ __('Passwort ändern') }}
{{ __('Passwort speichern') }}
{{ __('E-Mail-Adresse ändern') }}
@if ($user->pending_email)
{{ __('Ausstehende Änderung zu:') }} {{ $user->pending_email }}

{{ __('Der Bestätigungslink wurde an die neue Adresse gesendet. Bis zur Bestätigung bleibt die bisherige Adresse aktiv.') }}

{{ __('Änderung verwerfen') }}
@endif @include('partials.security-proof')

{{ __('Sie erhalten einen Bestätigungslink an die neue Adresse. Erst nach dem Klick wird die Änderung wirksam — Ihre bisherige Adresse bleibt bis dahin aktiv.') }}

{{ __('Bestätigungslink senden') }}
{{-- ============== PASSKEYS (Entscheidung 5) ============== --}}
{{ __('Passkeys') }} @if ($passkeys->isNotEmpty()) {{ trans_choice(':count Passkey|:count Passkeys', $passkeys->count(), ['count' => $passkeys->count()]) }} @endif

{{ __('Mit einem Passkey melden Sie sich per Fingerabdruck, Gesicht oder Geräte-PIN an — ohne Passwort und ohne Code aus der App. Passkeys gelten zugleich als zweiter Faktor und funktionieren nur auf der echten pressekonto-Seite.') }}

@if ($passkeys->isNotEmpty())
    @foreach ($passkeys as $passkey)
  • {{ $passkey->name }} @if ($passkey->authenticator) · {{ $passkey->authenticator }} @endif
    {{ __('Hinzugefügt am :date', ['date' => $passkey->created_at?->format('d.m.Y')]) }} · {{ $passkey->last_used_at ? __('zuletzt genutzt :date', ['date' => $passkey->last_used_at->format('d.m.Y H:i')]) : __('noch nicht genutzt') }}
    {{ __('Entfernen') }}
  • @endforeach
@endif
@include('partials.security-proof') {{ __('Passkey hinzufügen') }}

{{ __('Zwei-Faktor-Authentifizierung') }} @if ($twoFactorConfirmed) {{ __('Aktiv') }} @elseif ($twoFactorPending) {{ __('Bestätigung erforderlich') }} @endif
@if (! $twoFactorPending && ! $twoFactorConfirmed) {{-- Zustand 1: nicht eingerichtet --}}

{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}

@include('partials.security-proof') {{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
@elseif ($twoFactorPending) {{-- Zustand 2: Secret erzeugt, Bestätigung mit App-Code steht aus. Erst die Bestätigung aktiviert die Login-Abfrage. --}}
{{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi — QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}}
{!! $twoFactorQrSvg !!}

{{ __('Scannen Sie den QR-Code mit Ihrer Authenticator-App (z. B. 1Password, Google Authenticator) und geben Sie anschließend den angezeigten 6-stelligen Code ein. Erst danach ist die Zwei-Faktor-Authentifizierung aktiv.') }}

{{ __('Einrichtung bestätigen') }} {{ __('Abbrechen') }}
@else {{-- Zustand 3: aktiv. Recovery-Codes nur einmalig direkt nach Bestätigung bzw. Neu-Erzeugung sichtbar. --}}

{{ __('Die Zwei-Faktor-Authentifizierung ist aktiv. Beim Login wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.') }}

@if (! empty($recoveryCodes))
{{ __('Wiederherstellungs-Codes') }}

{{ __('Speichern Sie diese Codes jetzt an einem sicheren Ort — sie werden nur dieses eine Mal angezeigt. Jeder Code ersetzt einmalig Ihre Authenticator-App, falls Sie den Zugriff darauf verlieren.') }}

    @foreach ($recoveryCodes as $code)
  • {{ $code }}
  • @endforeach
@endif

{{ __('Zum Deaktivieren oder für neue Wiederherstellungs-Codes geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.') }}

{{ __('Neue Wiederherstellungs-Codes erzeugen') }} {{ __('Zwei-Faktor deaktivieren') }}
@endif
{{ __('Geräte und Anmeldungen') }} {{ __('Letzte :count Anmeldungen', ['count' => $recentLogins->count()]) }}

{{ __('Sind Sie noch auf einem fremden oder verlorenen Gerät angemeldet? Hier beenden Sie alle anderen Anmeldungen — auch dauerhafte („Angemeldet bleiben“). Dieses Gerät bleibt angemeldet.') }}

{{ __('Andere Geräte abmelden') }}
{{ __('Dieses Gerät') }}
{{ Str::limit(request()->userAgent() ?: __('Browser unbekannt'), 120) }}
@if ($currentSessionSince) {{ __('angemeldet :since', ['since' => $currentSessionSince->diffForHumans()]) }} @else {{ __('angemeldet') }} @endif
@forelse ($recentLogins as $login)
{{ $login->occurred_at->format('d.m.Y H:i') }}
{{ match ($login->meta['method'] ?? 'password') { 'magic-link' => __('Anmeldung per Magic-Link'), 'google' => __('Anmeldung mit Google'), 'passkey' => __('Anmeldung mit Passkey'), 'impersonation' => __('Zugriff durch den Support'), default => __('Anmeldung mit Passwort'), } }}
{{ $login->occurred_at->diffForHumans() }}
@empty
{{ __('Noch keine weiteren Anmeldungen protokolliert.') }}
@endforelse

{{ __('Laufende Anmeldungen auf anderen Geräten werden nicht einzeln angezeigt. Erkennen Sie eine Anmeldung nicht, ändern Sie Ihr Passwort und melden Sie die anderen Geräte ab.') }}