presseportale/resources/views/livewire/customer/security.blade.php
Kevin Adametz 5dfd622141 security: Andere Geräte abmelden, IP-weites Login-Limit, dauerhafte Sperrmarke, Passwortregeln, CSP im Melde-Modus
Restliche Phase 4 aus dem Sicherheitsaudit 07.10.2026:

- Sicherheitsseite: „Andere Geräte abmelden“ rotiert remember_token und
  beendet alle anderen Sessions, die aktuelle bleibt (nicht während einer
  Impersonation).
- LoginIpThrottle: 20 Fehlversuche je IP in 15 Minuten über alle Konten.
  Gezählt über das Failed-Event, damit Livewire-Login und Fortifys
  POST /login denselben Zähler teilen; Erfolg setzt ihn nicht zurück.
- Sperrmarke für Sessions in users.sessions_revoked_at_ms statt nur im
  Cache — übersteht cache:clear. Die alte Cache-Marke zählt übergangsweise
  mit und kann im nächsten Release entfallen.
- Neue und geänderte Passwörter: mindestens 12 Zeichen, in Produktion
  zusätzlich uncompromised() (HIBP, k-Anonymität). Bestehende bleiben gültig.
- Content-Security-Policy-Report-Only in Produktion; Meldungen über
  /csp-report nach storage/logs/csp-*.log, je Meldung höchstens einmal pro
  Stunde, ohne Query und tokenartige Pfadsegmente.

Migration: users.sessions_revoked_at_ms (users hat FULLTEXT, 25 MB, Sekunden).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 13:02:49 +00:00

656 lines
31 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
use App\Actions\Admin\UserImpersonation;
use App\Enums\SecurityChange;
use App\Livewire\Concerns\ConfirmsIdentity;
use App\Mail\EmailChangeConfirmation;
use App\Models\User;
use App\Services\Auth\PasswordChanger;
use App\Services\Auth\SecurityNotifier;
use App\Services\Auth\SessionRevocation;
use App\Services\Auth\StrongAuthentication;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\Str;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Laravel\Fortify\Features;
use Laravel\Passkeys\Actions\DeletePasskey;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Component;
new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends Component
{
use ConfirmsIdentity;
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
public string $email = '';
public string $twoFactorCode = '';
public string $passkeyName = '';
/**
* Recovery-Codes werden nur direkt nach Bestätigung bzw. Neu-Erzeugung
* angezeigt (einmalige Sichtbarkeit), nicht bei jedem Seitenaufruf.
* Gesperrt (Audit F03): nur der Server schaltet die Anzeige frei, ein
* Client-Update der Property darf die Codes nicht entschlüsseln.
*/
#[Locked]
public bool $showRecoveryCodes = false;
public function mount(): void
{
$this->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' => DB::table('sessions')
->where('user_id', $user->id)
->orderByDesc('last_activity')
->limit(5)
->get(['id', 'ip_address', 'user_agent', 'last_activity']),
];
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<header class="page-header">
<div class="min-w-0">
<div class="flex items-center gap-3 mb-3 flex-nowrap whitespace-nowrap">
<span class="badge hub dot">{{ __('User Backend') }}</span>
<span class="eyebrow muted">{{ __('Mein Bereich · Sicherheit') }}</span>
</div>
<h1 class="text-[30px] font-bold tracking-[-0.6px] leading-[1.15] m-0 text-[color:var(--color-ink)]">
{{ __('Konto-Sicherheit') }}
</h1>
<p class="text-[13px] leading-[1.55] mt-2 m-0 max-w-[640px] text-[color:var(--color-ink-2)]">
{{ __('Passwort, E-Mail und Zwei-Faktor-Authentifizierung verwalten.') }}
</p>
</div>
</header>
@if (session('security-status'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-ok-soft)] border-[color:var(--color-ok)]/30 text-[color:var(--color-gain-deep)]">
<flux:icon.check-circle class="size-[16px] flex-shrink-0" />
{{ session('security-status') }}
</div>
@endif
@if (session('security-error'))
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-center gap-2
bg-[color:var(--color-warn-soft,#fef2f2)] border-[color:var(--color-warn,#dc2626)]/30 text-[color:var(--color-warn,#b91c1c)]">
<flux:icon.exclamation-triangle class="size-[16px] flex-shrink-0" />
{{ session('security-error') }}
</div>
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<article class="panel p-5 space-y-2">
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('E-Mail') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] truncate">{{ $user->email }}</div>
<div>
@if ($user->email_verified_at)
<span class="badge ok">{{ __('Bestätigt') }}</span>
@else
<span class="badge warn">{{ __('Nicht bestätigt') }}</span>
@endif
</div>
</article>
<article class="panel p-5 space-y-2">
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Zwei-Faktor') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">
@if ($twoFactorConfirmed)
{{ __('Aktiv') }}
@elseif ($twoFactorPending)
{{ __('Einrichtung ausstehend') }}
@else
{{ __('Nicht aktiv') }}
@endif
</div>
<div>
@if ($twoFactorConfirmed)
<span class="badge ok">{{ __('Zusatzschutz aktiv') }}</span>
@elseif ($twoFactorPending)
<span class="badge warn">{{ __('Bestätigung erforderlich') }}</span>
@else
<span class="badge warn">{{ __('Empfohlen') }}</span>
@endif
</div>
</article>
<article class="panel p-5 space-y-2">
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Letzter Login') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">
{{ $user->last_login_at?->format('d.m.Y H:i') ?? __('Unbekannt') }}
</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)] truncate">
{{ $user->last_login_ip ?: __('Keine IP gespeichert') }}
</div>
</article>
<article class="panel p-5 space-y-2">
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Aktive Sessions') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">{{ $sessions->count() }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Aus den aktuellen Web-Sessions') }}
</div>
</article>
</section>
<div class="grid gap-6 lg:grid-cols-2">
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Passwort ändern') }}</span>
</div>
<form wire:submit="updatePassword" class="p-5 space-y-4">
<flux:input wire:model="current_password" type="password" :label="__('Aktuelles Passwort')" autocomplete="current-password" required />
<flux:input wire:model="password" type="password" :label="__('Neues Passwort')" :description:trailing="__('Mindestens 12 Zeichen.')" autocomplete="new-password" required />
<flux:input wire:model="password_confirmation" type="password" :label="__('Neues Passwort bestätigen')" autocomplete="new-password" required />
<div class="flex justify-end pt-2 border-t border-[color:var(--color-bg-rule)]">
<flux:button type="submit" variant="primary">{{ __('Passwort speichern') }}</flux:button>
</div>
</form>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('E-Mail-Adresse ändern') }}</span>
</div>
<form wire:submit="updateEmail" class="p-5 space-y-4">
@if ($user->pending_email)
<div class="px-3 py-2.5 rounded-[5px] border text-[12px] space-y-1.5
bg-[color:var(--color-bg-elev)] border-[color:var(--color-bg-rule)] text-[color:var(--color-ink-2)]">
<div class="flex items-center gap-2">
<flux:icon.clock class="size-[14px] flex-shrink-0" />
<span>{{ __('Ausstehende Änderung zu:') }} <strong class="text-[color:var(--color-ink)]">{{ $user->pending_email }}</strong></span>
</div>
<p class="m-0">{{ __('Der Bestätigungslink wurde an die neue Adresse gesendet. Bis zur Bestätigung bleibt die bisherige Adresse aktiv.') }}</p>
<flux:button size="sm" variant="subtle" wire:click="cancelPendingEmailChange">
{{ __('Änderung verwerfen') }}
</flux:button>
</div>
@endif
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
@include('partials.security-proof')
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Sie erhalten einen Bestätigungslink an die neue Adresse. Erst nach dem Klick wird die Änderung wirksam — Ihre bisherige Adresse bleibt bis dahin aktiv.') }}
</p>
<div class="flex justify-end pt-2 border-t border-[color:var(--color-bg-rule)]">
<flux:button type="submit" variant="primary">{{ __('Bestätigungslink senden') }}</flux:button>
</div>
</form>
</article>
</div>
{{-- ============== PASSKEYS (Entscheidung 5) ============== --}}
<article class="panel"
x-data="{ busy: false, error: null }"
x-on:passkey-register.window="busy = true; error = null;
const outcome = await window.PkPasskeys.register($event.detail.name);
if (outcome.ok) { await $wire.passkeyRegistered(); } else { error = outcome.error; }
busy = false;">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Passkeys') }}</span>
@if ($passkeys->isNotEmpty())
<span class="badge ok dot">{{ trans_choice(':count Passkey|:count Passkeys', $passkeys->count(), ['count' => $passkeys->count()]) }}</span>
@endif
</div>
<div class="p-5 space-y-5">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('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.') }}
</p>
@if ($passkeys->isNotEmpty())
<ul class="divide-y divide-[color:var(--color-bg-rule)] border border-[color:var(--color-bg-rule)] rounded-[5px] m-0 p-0 list-none">
@foreach ($passkeys as $passkey)
<li class="flex flex-col gap-2 px-4 py-3 sm:flex-row sm:items-center sm:justify-between" wire:key="passkey-{{ $passkey->id }}">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)] truncate">
{{ $passkey->name }}
@if ($passkey->authenticator)
<span class="font-normal text-[color:var(--color-ink-3)]">· {{ $passkey->authenticator }}</span>
@endif
</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('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') }}
</div>
</div>
<flux:button size="sm" variant="subtle" icon="trash"
wire:click="deletePasskey({{ $passkey->id }})"
wire:confirm="{{ __('Diesen Passkey wirklich entfernen?') }}">
{{ __('Entfernen') }}
</flux:button>
</li>
@endforeach
</ul>
@endif
<form wire:submit="startPasskeyRegistration" class="space-y-4 max-w-[420px]">
<flux:input wire:model="passkeyName" :label="__('Name für den neuen Passkey')" placeholder="{{ __('z. B. MacBook, iPhone') }}" maxlength="60" required />
@include('partials.security-proof')
<flux:button type="submit" variant="primary" icon="finger-print" x-bind:disabled="busy">
{{ __('Passkey hinzufügen') }}
</flux:button>
<p class="text-[12px] text-[color:var(--color-warn,#b91c1c)] m-0" x-show="error" x-text="error"></p>
</form>
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Zwei-Faktor-Authentifizierung') }}</span>
@if ($twoFactorConfirmed)
<span class="badge ok dot">{{ __('Aktiv') }}</span>
@elseif ($twoFactorPending)
<span class="badge warn dot">{{ __('Bestätigung erforderlich') }}</span>
@endif
</div>
<div class="p-5">
@if (! $twoFactorPending && ! $twoFactorConfirmed)
{{-- Zustand 1: nicht eingerichtet --}}
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Schützen Sie Ihren Account zusätzlich mit einer Authenticator-App (TOTP).') }}
</p>
<form wire:submit="enableTwoFactorAuthentication" class="mt-4 space-y-4 max-w-[420px]">
@include('partials.security-proof')
<flux:button type="submit" variant="primary">
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
</flux:button>
</form>
@elseif ($twoFactorPending)
{{-- Zustand 2: Secret erzeugt, Bestätigung mit App-Code steht aus.
Erst die Bestätigung aktiviert die Login-Abfrage. --}}
<div class="flex flex-col gap-5 lg:flex-row lg:items-start">
{{-- QR-Code: bg-white ist BEWUSST konstant in beiden Modi —
QR-Codes brauchen schwarz-auf-weiß für zuverlässiges Scannen. --}}
<div class="rounded-[6px] border border-[color:var(--color-bg-rule)] bg-white p-4 flex-shrink-0">
{!! $twoFactorQrSvg !!}
</div>
<div class="space-y-4 min-w-0 flex-1">
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('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.') }}
</p>
<form wire:submit="confirmTwoFactorAuthentication" class="space-y-3 max-w-[280px]">
<flux:input wire:model="twoFactorCode" :label="__('Code aus der App')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" required />
<div class="flex flex-wrap gap-2">
<flux:button type="submit" variant="primary">
{{ __('Einrichtung bestätigen') }}
</flux:button>
<flux:button wire:click="disableTwoFactorAuthentication" variant="subtle">
{{ __('Abbrechen') }}
</flux:button>
</div>
</form>
</div>
</div>
@else
{{-- Zustand 3: aktiv. Recovery-Codes nur einmalig direkt nach
Bestätigung bzw. Neu-Erzeugung sichtbar. --}}
<p class="text-[13px] text-[color:var(--color-ink-2)] m-0">
{{ __('Die Zwei-Faktor-Authentifizierung ist aktiv. Beim Login wird zusätzlich ein Code aus Ihrer Authenticator-App abgefragt.') }}
</p>
@if (! empty($recoveryCodes))
<div class="mt-4 space-y-3">
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
{{ __('Wiederherstellungs-Codes') }}
</div>
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('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.') }}
</p>
<ul class="grid grid-cols-2 gap-2 text-[11.5px] font-mono m-0 p-0 list-none max-w-[420px]">
@foreach ($recoveryCodes as $code)
<li class="rounded-[4px] bg-[color:var(--color-bg-elev)] border border-[color:var(--color-bg-rule)] px-2 py-1 text-[color:var(--color-ink)]">{{ $code }}</li>
@endforeach
</ul>
</div>
@endif
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] space-y-3">
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Zum Deaktivieren oder für neue Wiederherstellungs-Codes geben Sie einen aktuellen Code aus Ihrer Authenticator-App oder einen Wiederherstellungs-Code ein.') }}
</p>
<div class="max-w-[280px]">
<flux:input wire:model="securityCode" :label="__('Code aus der App oder Wiederherstellungs-Code')"
inputmode="numeric" autocomplete="one-time-code" placeholder="123456" />
</div>
<div class="flex flex-wrap gap-2">
<flux:button wire:click="regenerateRecoveryCodes" variant="filled">
{{ __('Neue Wiederherstellungs-Codes erzeugen') }}
</flux:button>
<flux:button wire:click="disableTwoFactorAuthentication" variant="danger">
{{ __('Zwei-Faktor deaktivieren') }}
</flux:button>
</div>
</div>
@endif
</div>
</article>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktive Sessions') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $sessions->count() }} {{ __('Einträge') }}
</span>
</div>
<div class="flex flex-col gap-3 px-5 pb-2 pt-3 sm:flex-row sm:items-center sm:justify-between">
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[560px]">
{{ __('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.') }}
</p>
<flux:button size="sm" variant="filled" icon="arrow-right-start-on-rectangle"
wire:click="logoutOtherDevices"
wire:confirm="{{ __('Alle anderen Geräte abmelden?') }}">
{{ __('Andere Geräte abmelden') }}
</flux:button>
</div>
<div class="divide-y divide-[color:var(--color-bg-rule)]">
@forelse ($sessions as $session)
<div class="flex flex-col gap-2 px-5 py-3 sm:flex-row sm:items-center sm:justify-between">
<div class="min-w-0">
<div class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $session->ip_address ?: __('IP unbekannt') }}
</div>
<div class="mt-0.5 text-[11.5px] text-[color:var(--color-ink-3)] truncate">
{{ Str::limit($session->user_agent ?: __('User-Agent unbekannt'), 120) }}
</div>
</div>
<span class="badge hub">
{{ \Carbon\Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }}
</span>
</div>
@empty
<div class="flex flex-col items-center justify-center px-5 py-10 text-center">
<div class="w-14 h-14 rounded-[6px] flex items-center justify-center mb-3
bg-[color:var(--color-hub-soft)] border border-[color:var(--color-hub-soft-2)] text-[color:var(--color-hub)]">
<flux:icon.shield-check class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">{{ __('Keine Sessions gefunden') }}</div>
<p class="mt-1 max-w-md text-[12px] text-[color:var(--color-ink-3)] m-0">
{{ __('Sobald Sessions protokolliert werden, erscheinen sie hier.') }}
</p>
</div>
@endforelse
</div>
</article>
</div>