Phase 8 (Rest) + Umbauten vom 10./11.06.: - Ein Titelbild pro PM (Cover 1280x580), SVG-Platzhalter-Set + Picker, PressReleaseCoverImage-Resolver - Lizenz-/Rechteformular nach "Lizenztyp Bildupload" (7 Lizenztypen, Personen-/Sachrechte-Status, bedingte Pflichtfelder, Risikohinweise) - Veroeffentlichungs-Box vereinfacht (Embargo aus der Form-UI entfernt), geplante Termine in Europe/Berlin (Speicherung UTC, DISPLAY_TIMEZONE) - Quota-Stub (users.press_release_quota) + monatlicher Reset-Command - Einreichungs-Modal einheitlich in Show/Create/Edit; Ghost-Buttons auf filled; PM-Editor-Layout responsive entkoppelt (.pr-editor-layout) KI-Pruef-Pipeline (Phasen 1-5 des Entwicklungsplans): - API-Haertung: status nicht mehr per API setzbar, eigene Submit-Route durch denselben Funnel (Blacklist, Quota, Status-Log) - Klassifikation Rot/Gelb/Gruen asynchron (Queue classification, OpenAI-Treiber + deterministischer Fallback), ki_audits-Audit-Log - Routing: Rot -> rejected + Mail, Gelb -> Review-Queue, Gruen -> Auto-Publish; Scheduler publiziert nur gruene faellige PMs - Content-Score 0-100 -> Stufe (Standard/Geprueft/Hochwertig) inkl. Editor-Panel und Badges; Re-Klassifikation/-Score bei Aenderung - Admin: KI-Badge + Filter, On-Demand-Pruefung mit Anbieter-Override Suite: 442 passed, 4 skipped. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
347 lines
15 KiB
PHP
347 lines
15 KiB
PHP
<?php
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Auth;
|
|
use Illuminate\Support\Facades\Hash;
|
|
use Illuminate\Support\Str;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\Rules\Password;
|
|
use Illuminate\Validation\ValidationException;
|
|
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
|
|
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
|
|
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
|
|
use Laravel\Fortify\Features;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Volt\Component;
|
|
|
|
new #[Layout('components.layouts.app'), Title('Konto-Sicherheit')] class extends Component
|
|
{
|
|
public string $current_password = '';
|
|
|
|
public string $password = '';
|
|
|
|
public string $password_confirmation = '';
|
|
|
|
public string $email = '';
|
|
|
|
public bool $confirmedTwoFactor = false;
|
|
|
|
public function mount(): void
|
|
{
|
|
$user = auth()->user();
|
|
$this->email = (string) $user->email;
|
|
$this->confirmedTwoFactor = ! is_null($user->two_factor_confirmed_at ?? null);
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
Auth::user()->forceFill([
|
|
'password' => Hash::make($validated['password']),
|
|
])->save();
|
|
|
|
$this->reset('current_password', 'password', 'password_confirmation');
|
|
|
|
session()->flash('security-status', __('Passwort aktualisiert.'));
|
|
}
|
|
|
|
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();
|
|
|
|
$user->forceFill([
|
|
'email' => $validated['email'],
|
|
'email_verified_at' => null,
|
|
])->save();
|
|
|
|
if (Features::enabled(Features::emailVerification())) {
|
|
$user->sendEmailVerificationNotification();
|
|
}
|
|
|
|
session()->flash('security-status', __('E-Mail-Adresse aktualisiert. Bitte erneut bestätigen, falls eine Verifizierung verschickt wurde.'));
|
|
}
|
|
|
|
public function enableTwoFactorAuthentication(EnableTwoFactorAuthentication $enable): void
|
|
{
|
|
$enable(auth()->user());
|
|
|
|
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung aktiviert. Scannen Sie den QR-Code mit Ihrer Authenticator-App.'));
|
|
}
|
|
|
|
public function disableTwoFactorAuthentication(DisableTwoFactorAuthentication $disable): void
|
|
{
|
|
$disable(auth()->user());
|
|
$this->confirmedTwoFactor = false;
|
|
|
|
session()->flash('security-status', __('Zwei-Faktor-Authentifizierung deaktiviert.'));
|
|
}
|
|
|
|
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generate): void
|
|
{
|
|
$generate(auth()->user());
|
|
|
|
session()->flash('security-status', __('Neue Wiederherstellungs-Codes erzeugt.'));
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
/** @var User $user */
|
|
$user = auth()->user();
|
|
$user->refresh();
|
|
|
|
$qrUrl = null;
|
|
$recoveryCodes = [];
|
|
|
|
if (! is_null($user->two_factor_secret ?? null) && Features::enabled(Features::twoFactorAuthentication())) {
|
|
try {
|
|
$qrUrl = $user->twoFactorQrCodeSvg();
|
|
$recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true) ?: [];
|
|
} catch (\Throwable) {
|
|
$qrUrl = null;
|
|
$recoveryCodes = [];
|
|
}
|
|
}
|
|
|
|
return [
|
|
'user' => $user,
|
|
'twoFactorEnabled' => ! is_null($user->two_factor_secret ?? null),
|
|
'twoFactorQrSvg' => $qrUrl,
|
|
'recoveryCodes' => $recoveryCodes,
|
|
'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="grid items-end gap-8" style="grid-template-columns:1fr auto;">
|
|
<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
|
|
|
|
{{-- ============== 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)]">
|
|
{{ $twoFactorEnabled ? __('Aktiv') : __('Nicht aktiv') }}
|
|
</div>
|
|
<div>
|
|
@if ($twoFactorEnabled)
|
|
<span class="badge ok">{{ __('Zusatzschutz aktiv') }}</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">
|
|
<flux:input wire:model="email" type="email" :label="__('Neue E-Mail-Adresse')" autocomplete="email" required />
|
|
<p class="text-[12px] text-[color:var(--color-ink-3)] m-0">
|
|
{{ __('Nach der Änderung kann eine erneute Bestätigung der E-Mail-Adresse erforderlich sein.') }}
|
|
</p>
|
|
<div class="flex justify-end pt-2 border-t border-[color:var(--color-bg-rule)]">
|
|
<flux:button type="submit" variant="primary">{{ __('E-Mail speichern') }}</flux:button>
|
|
</div>
|
|
</form>
|
|
</article>
|
|
</div>
|
|
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Zwei-Faktor-Authentifizierung') }}</span>
|
|
@if ($twoFactorEnabled)
|
|
<span class="badge ok dot">{{ __('Aktiv') }}</span>
|
|
@endif
|
|
</div>
|
|
<div class="p-5">
|
|
@if (! $twoFactorEnabled)
|
|
<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>
|
|
<flux:button class="mt-4" wire:click="enableTwoFactorAuthentication" variant="primary">
|
|
{{ __('Zwei-Faktor-Authentifizierung aktivieren') }}
|
|
</flux:button>
|
|
@else
|
|
@if ($twoFactorQrSvg)
|
|
<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-3 min-w-0">
|
|
<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).') }}
|
|
</p>
|
|
@if (! empty($recoveryCodes))
|
|
<div class="text-[11px] uppercase tracking-[0.6px] font-semibold text-[color:var(--color-ink-3)]">
|
|
{{ __('Wiederherstellungs-Codes') }}
|
|
</div>
|
|
<ul class="grid grid-cols-2 gap-2 text-[11.5px] font-mono m-0 p-0 list-none">
|
|
@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>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
@endif
|
|
|
|
<div class="mt-5 pt-4 border-t border-[color:var(--color-bg-rule)] 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>
|
|
@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>
|