Audit: dev/sicherheit/SICHERHEITSAUDIT-2026-10-07.md, Plan und Entscheidungen: dev/sicherheit/ENTWICKLUNGSPLAN-SICHERHEIT-2026-10-07.md. - S-01 Legacy-Rechnungen fremder Kunden nur mit billing:manage (auch Kundenroute). - S-02 Kontaktzuordnung auf der Firmenanzeige verlangt companies:write, $id gesperrt. - S-03 CompanyMembershipObserver: automatische Firmenrechte werden bei jeder Adress-/Zuordnungsänderung sofort neu bewertet; memberships:resync-auto. - S-04 Passwortwechsel beendet alle anderen Sessions (PasswordChanger), Reset alle Sessions und offene Login-Links; Fortify PUT /user/password abgeschaltet. API-Tokens bleiben bestehen. - S-05 Google: email_verified Pflicht, feste Bindung über die Provider-ID; Erstverknüpfung bestehender Konten außerhalb gmail/hd nur per Bestätigungslink in derselben Browser-Session. - S-06 Frischer Identitätsnachweis (Passwort, App-Code, E-Mail-Code oder Passkey) vor E-Mail-Wechsel, erster 2FA-Einrichtung und Token-Erzeugung; Hinweis-Mails an die (bisherige) Kontoadresse. - Entscheidung 5: Passkeys (Fortify, @laravel/passkeys) für alle; Super-Admins und Admins bestätigen die Session für den Admin-Bereich per Passkey oder App-Code (EnsureStrongAdminAuthentication, Notfallschalter AUTH_REQUIRE_STRONG_ADMIN); Reset des zweiten Faktors durch Super-Admins. Neue Migration: create_passkeys_table. Suite 2.403 grün (1 übersprungen), Larastan ohne Meldung. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
636 lines
30 KiB
PHP
636 lines
30 KiB
PHP
<?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\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.'));
|
||
}
|
||
|
||
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')" 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="px-5 pb-2 pt-3">
|
||
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0">
|
||
{{ __('Hier sehen Sie die letzten bekannten Web-Sessions Ihres Kontos. Abmelden erfolgt aktuell über das Nutzer-Menü.') }}
|
||
</p>
|
||
</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>
|