presseportale/resources/views/livewire/admin/contact-messages/index.blade.php
Kevin Adametz 06d2889a4d feat: Spamschutz für Kontakt-Widget und Registrierung mit Cloudflare Turnstile
- Turnstile für Gäste und unbestätigte Konten (TurnstileVerifier,
  <x-turnstile>); ist Cloudflare nicht erreichbar, wird durchgelassen und
  im Verlauf, in der Kontakt-Queue und in der Betriebsüberwachung markiert
- Inhaltsfilter des Kontaktformulars (fremde Links, Floskel mit
  Seitentitel, Zeichensalat, Sperrliste, Absenden unter 3 Sekunden);
  Treffer landen in der Spam-Quarantäne ohne Mail, Löschung nach 30 Tagen
- Admin: Filter „Spam“, Sammelaktion, „Kein Spam“ holt die Mail nach
- Registrierung lehnt Zufallsnamen ab; users:prune-bot-registrations
  (täglicher Lauf erst mit USERS_PRUNE_BOT_REGISTRATIONS=true)
- contact-messages:quarantine-spam für den Altbestand
- CSP erlaubt challenges.cloudflare.com, Log-Kanal spam
- Datenschutzerklärung: Abschnitt 6a Cloudflare Turnstile (Vorschlag)

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

248 lines
12 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\Enums\ContactMessageStatus;
use App\Enums\ContactMessageTopic;
use App\Models\ContactMessage;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Attributes\Url;
use Livewire\Component;
use Livewire\WithPagination;
new #[Layout('components.layouts.app'), Title('Kontakt')] class extends Component
{
use WithPagination;
#[Url(as: 'status', except: 'open')]
public string $statusFilter = 'open';
#[Url(as: 'topic', except: 'all')]
public string $topicFilter = 'all';
/** @var list<string> Auswahl für die Sammelaktion „Als Spam markieren“. */
public array $selected = [];
public string $notification = '';
public function updatedStatusFilter(): void
{
$this->selected = [];
$this->resetPage();
}
/**
* Sammelaktion: ausgewählte Anliegen in die Spam-Quarantäne legen
* (Spamschutz B3). Keine Mail, Löschung nach 30 Tagen.
*/
public function markSelectedAsSpam(): void
{
$this->authorize('compliance:manage');
$messages = ContactMessage::query()
->whereKey(array_map('intval', $this->selected))
->where('status', '!=', ContactMessageStatus::Spam->value)
->get();
foreach ($messages as $message) {
$message->markAsSpam(auth()->user());
}
$this->selected = [];
$this->notification = __(':count Anliegen als Spam markiert.', ['count' => $messages->count()]);
}
public function updatedTopicFilter(): void
{
$this->selected = [];
$this->resetPage();
}
public function with(): array
{
$messages = ContactMessage::query()
->when($this->statusFilter === 'open', fn ($query) => $query->open())
->when(! in_array($this->statusFilter, ['open', 'all'], true), fn ($query) => $query->where('status', $this->statusFilter))
->when($this->topicFilter !== 'all', fn ($query) => $query->where('topic', $this->topicFilter))
->latest()
->paginate(50);
return [
'messages' => $messages,
'topicOptions' => ContactMessageTopic::cases(),
'openCount' => ContactMessage::query()->open()->count(),
'spamCount' => ContactMessage::query()->where('status', ContactMessageStatus::Spam->value)->count(),
'totalCount' => ContactMessage::query()->where('status', '!=', ContactMessageStatus::Spam->value)->count(),
];
}
public function statusBadgeClass(ContactMessageStatus $status): string
{
return match ($status) {
ContactMessageStatus::Open => 'warn',
ContactMessageStatus::InProgress => 'hub',
ContactMessageStatus::Resolved => 'ok',
ContactMessageStatus::Rejected => 'err',
ContactMessageStatus::Spam => 'muted',
};
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Kontakt')"
:title="__('Kontakt')"
:description="__('Anliegen aus dem Kontakt-Widget und dem Kontaktformular: Kontaktaufnahmen, Fehlermeldungen, PM-Meldungen und Feedback – manuelle Bearbeitung.')"
/>
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-3">
<x-portal.stat-card variant="primary" :label="__('Offen')" :value="number_format($openCount)">
<x-slot:meta>{{ __('zu bearbeiten') }}</x-slot:meta>
<x-slot:trend>{{ __('offen + in Bearbeitung') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Gesamt')" :value="number_format($totalCount)">
<x-slot:meta>{{ __('alle Anliegen') }}</x-slot:meta>
<x-slot:trend>{{ __('inkl. erledigt, ohne Spam') }}</x-slot:trend>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Spam')" :value="number_format($spamCount)">
<x-slot:meta>{{ __('in Quarantäne') }}</x-slot:meta>
<x-slot:trend>{{ __('ohne Mail, nach :days Tagen gelöscht', ['days' => \App\Models\ContactMessage::SPAM_RETENTION_DAYS]) }}</x-slot:trend>
</x-portal.stat-card>
</section>
{{-- ============== FILTER ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Filter') }}</span>
</div>
<div class="p-5 flex flex-col gap-4 sm:flex-row">
<flux:select wire:model.live="statusFilter" class="w-full sm:w-56">
<option value="open">{{ __('Offen & in Bearbeitung') }}</option>
<option value="all">{{ __('Alle Status') }}</option>
<option value="resolved">{{ __('Erledigt') }}</option>
<option value="rejected">{{ __('Abgelehnt') }}</option>
<option value="spam">{{ __('Spam (Quarantäne)') }}</option>
</flux:select>
<flux:select wire:model.live="topicFilter" class="w-full sm:w-56">
<option value="all">{{ __('Alle Anliegen') }}</option>
@foreach ($topicOptions as $topicOption)
<option value="{{ $topicOption->value }}">{{ $topicOption->label() }}</option>
@endforeach
</flux:select>
</div>
</article>
{{-- ============== TABELLE ============== --}}
<article class="panel overflow-hidden">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
<div class="flex items-center gap-3">
@if (count($selected) > 0)
<flux:button size="sm" variant="danger" icon="no-symbol" wire:click="markSelectedAsSpam"
wire:confirm="{{ __('Ausgewählte Anliegen als Spam markieren? Es geht keine Mail raus, nach 30 Tagen werden sie gelöscht.') }}">
{{ __(':count als Spam markieren', ['count' => count($selected)]) }}
</flux:button>
@endif
<span class="text-[11.5px] text-[color:var(--color-ink-3)]">
{{ __(':count Einträge', ['count' => $messages->total()]) }}
</span>
</div>
</div>
@if ($notification)
<div class="mx-5 mt-4 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" />
{{ $notification }}
</div>
@endif
<flux:table>
<flux:table.columns>
<flux:table.column class="w-8"><span class="sr-only">{{ __('Auswahl') }}</span></flux:table.column>
<flux:table.column>{{ __('Anliegen') }}</flux:table.column>
<flux:table.column>{{ __('Status') }}</flux:table.column>
<flux:table.column>{{ __('Absender') }}</flux:table.column>
<flux:table.column>{{ __('Portal') }}</flux:table.column>
<flux:table.column>{{ __('Eingegangen') }}</flux:table.column>
<flux:table.column>{{ __('Aktion') }}</flux:table.column>
</flux:table.columns>
<flux:table.rows>
@forelse ($messages as $message)
<flux:table.row :key="$message->id">
<flux:table.cell>
@unless ($message->isSpam())
<input type="checkbox" wire:model.live="selected" value="{{ $message->id }}"
aria-label="{{ __('Anliegen :ref auswählen', ['ref' => $message->reference()]) }}"
class="size-4 cursor-pointer accent-[color:var(--color-hub)]" />
@endunless
</flux:table.cell>
<flux:table.cell>
<span class="text-[13px] font-semibold text-[color:var(--color-ink)]">
{{ $message->topic->label() }}
</span>
<span class="block text-[11px] font-mono text-[color:var(--color-ink-4)]">
{{ $message->reference() }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">
{{ $message->status->label() }}
</span>
@if ($message->isSpam() && $message->spamReasonLabel())
<span class="block mt-1 text-[11px] text-[color:var(--color-ink-3)]">{{ $message->spamReasonLabel() }}</span>
@elseif ($message->skippedBotCheck())
<span class="badge warn mt-1">{{ __('ohne Bot-Prüfung') }}</span>
@endif
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink)]">
{{ $message->name ?: __('anonym') }}
</span>
<span class="block text-[11.5px] text-[color:var(--color-ink-3)]">
{{ $message->email }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12.5px] text-[color:var(--color-ink-3)]">
{{ $message->portal ?: '—' }}
</span>
</flux:table.cell>
<flux:table.cell>
<span class="text-[12px] text-[color:var(--color-ink-3)]">
{{ $message->created_at?->format('d.m.Y H:i') ?? '-' }}
</span>
</flux:table.cell>
<flux:table.cell>
<flux:button size="sm" variant="filled" icon="eye"
href="{{ route('admin.contact-messages.show', $message->id) }}" wire:navigate>
{{ __('Öffnen') }}
</flux:button>
</flux:table.cell>
</flux:table.row>
@empty
<flux:table.row>
<flux:table.cell colspan="7">
<div class="flex flex-col items-center justify-center px-4 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.chat-bubble-left-right class="size-6" />
</div>
<div class="text-[14px] font-semibold text-[color:var(--color-ink)] mb-1">
{{ __('Keine Anliegen in dieser Ansicht') }}
</div>
<p class="text-[12.5px] text-[color:var(--color-ink-3)] m-0 max-w-[420px]">
{{ __('Anliegen aus dem Kontakt-Widget (Kontaktaufnahme, Fehlermeldung, PM-Meldung, Feedback) laufen hier auf.') }}
</p>
</div>
</flux:table.cell>
</flux:table.row>
@endforelse
</flux:table.rows>
</flux:table>
<x-portal.table-pagination :paginator="$messages" />
</article>
</div>