- 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>
278 lines
12 KiB
PHP
278 lines
12 KiB
PHP
<?php
|
|
|
|
use App\Enums\ContactMessageStatus;
|
|
use App\Models\ContactMessage;
|
|
use App\Services\Web\ContactMessageNotifier;
|
|
use Livewire\Attributes\Layout;
|
|
use Livewire\Attributes\Locked;
|
|
use Livewire\Attributes\Title;
|
|
use Livewire\Component;
|
|
|
|
new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class extends Component
|
|
{
|
|
#[Locked]
|
|
public int $id;
|
|
|
|
public string $adminNote = '';
|
|
|
|
public string $notification = '';
|
|
|
|
public function mount(int $id): void
|
|
{
|
|
$this->id = $id;
|
|
$this->adminNote = (string) ($this->message()->admin_note ?? '');
|
|
}
|
|
|
|
public function markInProgress(): void
|
|
{
|
|
$this->authorize('compliance:manage');
|
|
|
|
$this->message()->update(['status' => ContactMessageStatus::InProgress->value]);
|
|
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
|
|
}
|
|
|
|
public function resolve(): void
|
|
{
|
|
$this->authorize('compliance:manage');
|
|
|
|
$this->finish(ContactMessageStatus::Resolved, __('Anliegen als erledigt markiert.'));
|
|
}
|
|
|
|
public function reject(): void
|
|
{
|
|
$this->authorize('compliance:manage');
|
|
|
|
$this->finish(ContactMessageStatus::Rejected, __('Anliegen abgelehnt.'));
|
|
}
|
|
|
|
public function reopen(): void
|
|
{
|
|
$this->authorize('compliance:manage');
|
|
|
|
$this->message()->update([
|
|
'status' => ContactMessageStatus::Open->value,
|
|
'resolved_by_user_id' => null,
|
|
'resolved_at' => null,
|
|
]);
|
|
$this->notification = __('Anliegen wieder geöffnet.');
|
|
}
|
|
|
|
public function markAsSpam(): void
|
|
{
|
|
$this->authorize('compliance:manage');
|
|
|
|
$this->message()->markAsSpam(auth()->user());
|
|
$this->notification = __('Als Spam markiert. Nach :days Tagen wird das Anliegen gelöscht.', ['days' => ContactMessage::SPAM_RETENTION_DAYS]);
|
|
}
|
|
|
|
/**
|
|
* Fehlalarm der Spam-Quarantäne: wieder öffnen und die Team-Mail
|
|
* nachholen, die beim Eingang zurückgehalten wurde.
|
|
*/
|
|
public function releaseFromSpam(ContactMessageNotifier $notifier): void
|
|
{
|
|
$this->authorize('compliance:manage');
|
|
|
|
$message = $this->message();
|
|
|
|
if ($message->releaseFromSpam()) {
|
|
$notifier->notify($message->refresh());
|
|
$this->notification = __('Kein Spam: Anliegen wieder geöffnet, die Mail an das Team wurde nachgeholt.');
|
|
|
|
return;
|
|
}
|
|
|
|
$this->notification = __('Kein Spam: Anliegen wieder geöffnet.');
|
|
}
|
|
|
|
private function finish(ContactMessageStatus $status, string $notification): void
|
|
{
|
|
$this->message()->update([
|
|
'status' => $status->value,
|
|
'admin_note' => $this->adminNote !== '' ? $this->adminNote : null,
|
|
'resolved_by_user_id' => auth()->id(),
|
|
'resolved_at' => now(),
|
|
]);
|
|
|
|
$this->notification = $notification;
|
|
}
|
|
|
|
private function message(): ContactMessage
|
|
{
|
|
return ContactMessage::query()->with(['user', 'resolver'])->findOrFail($this->id);
|
|
}
|
|
|
|
public function with(): array
|
|
{
|
|
return ['message' => $this->message()];
|
|
}
|
|
|
|
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
|
|
size="sm"
|
|
:badge="__('Admin Backend')"
|
|
:eyebrow="__('Kontakt · Anliegen')"
|
|
:title="$message->topic->label()"
|
|
>
|
|
<x-slot:meta>
|
|
<span class="badge dot {{ $this->statusBadgeClass($message->status) }}">{{ $message->status->label() }}</span>
|
|
</x-slot:meta>
|
|
<x-slot:actions>
|
|
<flux:button icon="arrow-left" variant="filled" href="{{ route('admin.contact-messages.index') }}" wire:navigate>
|
|
{{ __('Zurück') }}
|
|
</flux:button>
|
|
</x-slot:actions>
|
|
</x-portal.page-header>
|
|
|
|
@if ($notification)
|
|
<div x-data="{ show: true }" x-init="setTimeout(() => show = false, 3000)" x-show="show" x-transition
|
|
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" />
|
|
{{ $notification }}
|
|
</div>
|
|
@endif
|
|
|
|
<div class="grid gap-6 xl:grid-cols-3">
|
|
{{-- ============== ANLIEGEN ============== --}}
|
|
<article class="panel xl:col-span-2">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Anliegen') }}</span>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<div class="rounded-[5px] border border-[color:var(--color-bg-rule)] bg-[color:var(--color-bg-elev)] p-4 text-[13px] text-[color:var(--color-ink)] whitespace-pre-line">
|
|
{{ $message->message ?: __('Keine Nachricht hinterlegt.') }}
|
|
</div>
|
|
|
|
<dl class="grid gap-3 sm:grid-cols-2 text-[12.5px]">
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Vorgangsnummer') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)] font-mono">{{ $message->reference() }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Name') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)]">{{ $message->name ?: __('anonym') }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Absender') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)] break-all">
|
|
@if ($message->email)
|
|
<a href="mailto:{{ $message->email }}"
|
|
class="text-[color:var(--color-hub)] underline underline-offset-2">{{ $message->email }}</a>
|
|
@else
|
|
{{ __('anonym') }}
|
|
@endif
|
|
</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Eingegangen') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)]">{{ $message->created_at?->format('d.m.Y H:i') ?? '-' }}</dd>
|
|
</div>
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Portal') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)]">{{ $message->portal ?: '—' }}</dd>
|
|
</div>
|
|
@if ($message->spamReasonLabel())
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Spamschutz') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)]">{{ $message->spamReasonLabel() }}</dd>
|
|
</div>
|
|
@endif
|
|
@if ($message->skippedBotCheck())
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Bot-Prüfung') }}</dt>
|
|
<dd><span class="badge warn">{{ __('Cloudflare nicht erreichbar — bitte prüfen') }}</span></dd>
|
|
</div>
|
|
@endif
|
|
@if ($message->user)
|
|
<div>
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Benutzerkonto') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)]">
|
|
{{ $message->user->name }}
|
|
@if (\Illuminate\Support\Facades\Route::has('admin.users.show'))
|
|
<a href="{{ route('admin.users.show', $message->user->id) }}" wire:navigate
|
|
class="ml-2 text-[12px] text-[color:var(--color-hub)] underline underline-offset-2">{{ __('öffnen') }}</a>
|
|
@endif
|
|
</dd>
|
|
</div>
|
|
@endif
|
|
@if ($message->page_url)
|
|
<div class="sm:col-span-2">
|
|
<dt class="text-[11px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Abgesendet von Seite') }}</dt>
|
|
<dd class="text-[color:var(--color-ink)]">
|
|
@if (str($message->page_url)->startsWith(['http://', 'https://']))
|
|
<a href="{{ $message->page_url }}" target="_blank" rel="noopener"
|
|
class="text-[color:var(--color-hub)] underline underline-offset-2 break-all">{{ $message->page_url }}</a>
|
|
@else
|
|
<span class="break-all">{{ $message->page_url }}</span>
|
|
@endif
|
|
</dd>
|
|
</div>
|
|
@endif
|
|
</dl>
|
|
</div>
|
|
</article>
|
|
|
|
{{-- ============== BEARBEITUNG ============== --}}
|
|
<article class="panel">
|
|
<div class="panel-head">
|
|
<span class="section-eyebrow">{{ __('Bearbeitung') }}</span>
|
|
</div>
|
|
<div class="p-5 space-y-4">
|
|
<flux:field>
|
|
<flux:textarea wire:model="adminNote" :label="__('Interne Notiz')" rows="4"
|
|
:placeholder="__('Wie wurde das Anliegen bearbeitet?')" />
|
|
</flux:field>
|
|
|
|
@if ($message->resolver)
|
|
<p class="text-[12px] text-[color:var(--color-ink-3)]">
|
|
{{ __('Zuletzt bearbeitet von :name am :date', [
|
|
'name' => $message->resolver->name,
|
|
'date' => $message->resolved_at?->format('d.m.Y H:i') ?? '-',
|
|
]) }}
|
|
</p>
|
|
@endif
|
|
|
|
<div class="flex flex-col gap-2 pt-2 border-t border-[color:var(--color-bg-rule)]">
|
|
@if ($message->status->isOpen())
|
|
@if ($message->status !== \App\Enums\ContactMessageStatus::InProgress)
|
|
<flux:button variant="filled" icon="clock" wire:click="markInProgress">
|
|
{{ __('In Bearbeitung nehmen') }}
|
|
</flux:button>
|
|
@endif
|
|
<flux:button variant="primary" icon="check" wire:click="resolve">
|
|
{{ __('Als erledigt markieren') }}
|
|
</flux:button>
|
|
<flux:button variant="danger" icon="x-mark" wire:click="reject">
|
|
{{ __('Ablehnen') }}
|
|
</flux:button>
|
|
<flux:button variant="ghost" icon="no-symbol" wire:click="markAsSpam">
|
|
{{ __('Als Spam markieren') }}
|
|
</flux:button>
|
|
@elseif ($message->isSpam())
|
|
<flux:button variant="primary" icon="arrow-uturn-left" wire:click="releaseFromSpam">
|
|
{{ __('Kein Spam — wieder öffnen') }}
|
|
</flux:button>
|
|
@else
|
|
<flux:button variant="filled" icon="arrow-path" wire:click="reopen">
|
|
{{ __('Wieder öffnen') }}
|
|
</flux:button>
|
|
@endif
|
|
</div>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|