feat(security): Sicherheitsseite zeigt dieses Gerät und die letzten Anmeldungen

Die Liste „Aktive Sessions“ las die sessions-Tabelle, Live speichert Sessions
in Redis — sie war immer leer. Jetzt: „Dieses Gerät“ (seit wann angemeldet)
und die letzten fünf Anmeldungen mit Anmeldeweg aus dem Aktivitätsverlauf,
ohne IP; Kachel „Anmeldungen in 30 Tagen“ (Roadmap 4.1).

Changelog: Verbessert
Public: Die Sicherheitsseite zeigt Ihre letzten Anmeldungen und wie Sie sich jeweils angemeldet haben.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-10-09 13:35:54 +00:00
parent 53e3b270e0
commit 29e68330ca
2 changed files with 93 additions and 26 deletions

View file

@ -1,15 +1,16 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Enums\ActivityType;
use App\Enums\SecurityChange;
use App\Livewire\Concerns\ConfirmsIdentity;
use App\Mail\EmailChangeConfirmation;
use App\Models\ActivityEvent;
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;
@ -309,11 +310,23 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
'recoveryCodes' => $recoveryCodes,
'securityProofKind' => $this->securityProofKind($user),
'passkeys' => $user->passkeys()->latest()->get(['id', 'name', 'credential', 'last_used_at', 'created_at']),
'sessions' => DB::table('sessions')
// 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)
->orderByDesc('last_activity')
->where('type', ActivityType::UserLoggedIn)
->orderByDesc('occurred_at')
->limit(5)
->get(['id', 'ip_address', 'user_agent', 'last_activity']),
->get(['occurred_at', 'meta']),
'loginsLast30Days' => ActivityEvent::query()
->where('user_id', $user->id)
->where('type', ActivityType::UserLoggedIn)
->where('occurred_at', '>=', now()->subDays(30))
->count(),
];
}
}; ?>
@ -405,11 +418,11 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
<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') }}
{{ __('Anmeldungen') }}
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">{{ $sessions->count() }}</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)]">{{ $loginsLast30Days }}</div>
<div class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __('Aus den aktuellen Web-Sessions') }}
{{ __('In den letzten 30 Tagen') }}
</div>
</article>
</section>
@ -608,9 +621,9 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Aktive Sessions') }}</span>
<span class="section-eyebrow">{{ __('Geräte und Anmeldungen') }}</span>
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $sessions->count() }} {{ __('Einträge') }}
{{ __('Letzte :count Anmeldungen', ['count' => $recentLogins->count()]) }}
</span>
</div>
<div class="flex flex-col gap-3 px-5 pb-2 pt-3 sm:flex-row sm:items-center sm:justify-between">
@ -625,32 +638,50 @@ new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends
</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)]">
{{ __('Dieses Gerät') }}
</div>
<div class="mt-0.5 text-[11.5px] text-[color:var(--color-ink-3)] truncate">
{{ Str::limit(request()->userAgent() ?: __('Browser unbekannt'), 120) }}
</div>
</div>
<span class="badge ok">
@if ($currentSessionSince)
{{ __('angemeldet :since', ['since' => $currentSessionSince->diffForHumans()]) }}
@else
{{ __('angemeldet') }}
@endif
</span>
</div>
@forelse ($recentLogins as $login)
<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') }}
{{ $login->occurred_at->format('d.m.Y H:i') }}
</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 class="mt-0.5 text-[11.5px] text-[color:var(--color-ink-3)]">
{{ 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'),
} }}
</div>
</div>
<span class="badge hub">
{{ \Carbon\Carbon::createFromTimestamp($session->last_activity)->diffForHumans() }}
</span>
<span class="badge hub">{{ $login->occurred_at->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 class="px-5 py-6 text-center text-[12px] text-[color:var(--color-ink-3)]">
{{ __('Noch keine weiteren Anmeldungen protokolliert.') }}
</div>
@endforelse
</div>
<p class="px-5 pb-4 pt-2 text-[11.5px] text-[color:var(--color-ink-3)] m-0">
{{ __('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.') }}
</p>
</article>
</div>