presseportale/resources/views/livewire/admin/newsletter/sync.blade.php
Kevin Adametz 77f8f9cb35 security: Rechteausweitung schließen & Admin-Panel feingranular gaten (K1, H1, H2, M8)
- users:manage/roles:manage auf allen User-/Rollen-Masken erzwungen;
  is_super_admin aus $fillable entfernt und nur per autorisiertem
  forceFill setzbar; zentraler Gate::before-Super-Admin-Kurzschluss (K1/M8).
- Nicht-Super-Admins können Super-Admin-Konten nicht mehr bearbeiten (Fix#2);
  Impersonation mit Rangprüfung, keine Token-Erstellung während Impersonation (H1).
- Destruktive/verwaltende Admin-Aktionen (CRM/Billing/Compliance/Struktur)
  hängen an neuen admin-only Permissions; Editor behält reine Content-Rechte (H2).
- Kanonischer Permission-Katalog (App\Authorization\Permission) als Single
  Source of Truth für Seeder und Monitoring.

Deployment: `php artisan db:seed --class=RolesAndPermissionsSeeder` auf allen
Umgebungen ausführen (neue Permissions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 13:48:50 +00:00

173 lines
7.3 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\Models\NewsletterSubscription;
use App\Services\Admin\AdminPerformanceCache;
use App\Services\Newsletter\NewsletterSyncService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Volt\Component;
new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends Component
{
public ?string $syncMessage = null;
public ?string $dryRunMessage = null;
public function triggerDryRun(): void
{
$this->authorize('billing:manage');
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {
$this->dryRunMessage = 'Dry-Run: Kein Datensatz fuer Vorschau vorhanden.';
return;
}
$action = ($subscription->is_confirmed && $subscription->unsubscribed_at === null)
? 'subscribe'
: 'unsubscribe';
$this->dryRunMessage = "Dry-Run: Es wuerde {$action} fuer Subscription #{$subscription->id} ({$subscription->email}) ausgefuehrt.";
}
public function triggerTestSync(): void
{
$this->authorize('billing:manage');
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {
$this->syncMessage = 'Kein Datensatz fuer Test-Sync vorhanden.';
return;
}
app(NewsletterSyncService::class)->syncSubscription($subscription);
$action = ($subscription->is_confirmed && $subscription->unsubscribed_at === null)
? 'subscribe'
: 'unsubscribe';
$this->syncMessage = "Test-Sync ausgefuehrt ({$action}) fuer Subscription #{$subscription->id}.";
if ((string) config('newsletter.sync.provider') === 'placeholder') {
$this->syncMessage .= ' Hinweis: Platzhalter-Provider – es wurde kein externes System aufgerufen.';
}
}
public function with(): array
{
return [
'stats' => $this->stats(),
'syncConfig' => [
'enabled' => (bool) config('newsletter.sync.enabled'),
'provider' => (string) config('newsletter.sync.provider'),
'endpoint' => (string) (config('newsletter.sync.endpoint') ?? '-'),
'timeout' => (int) config('newsletter.sync.timeout', 10),
],
];
}
/**
* @return array{total: int, confirmed: int, pending: int, unsubscribed: int}
*/
private function stats(): array
{
return app(AdminPerformanceCache::class)->remember(AdminPerformanceCache::NewsletterStats, AdminPerformanceCache::StatsTtl, function (): array {
$stats = NewsletterSubscription::query()
->toBase()
->selectRaw('COUNT(*) as total')
->selectRaw('SUM(CASE WHEN is_confirmed = ? THEN 1 ELSE 0 END) as confirmed', [true])
->selectRaw('SUM(CASE WHEN is_confirmed = ? THEN 1 ELSE 0 END) as pending', [false])
->selectRaw('SUM(CASE WHEN unsubscribed_at IS NOT NULL THEN 1 ELSE 0 END) as unsubscribed')
->first();
return [
'total' => (int) ($stats->total ?? 0),
'confirmed' => (int) ($stats->confirmed ?? 0),
'pending' => (int) ($stats->pending ?? 0),
'unsubscribed' => (int) ($stats->unsubscribed ?? 0),
];
});
}
}; ?>
<div class="space-y-8">
{{-- ============== PAGE HEADER ============== --}}
<x-portal.page-header
:badge="__('Admin Backend')"
:eyebrow="__('Administration · Newsletter')"
:title="__('Newsletter Synchronisierung')"
:description="__('Vorbereitung fuer die kuenftige externe API-Anbindung. Aktuell ist nur das technische Grundgeruest aktiv.')"
>
<x-slot:meta>
@if ($syncConfig['enabled'])
<span class="badge ok dot">{{ __('Sync aktiv') }}</span>
@else
<span class="badge dot">{{ __('Sync deaktiviert') }}</span>
@endif
</x-slot:meta>
<x-slot:actions>
<flux:button size="sm" variant="filled" icon="eye" wire:click="triggerDryRun">
{{ __('Dry Run') }}
</flux:button>
<flux:button size="sm" variant="primary" icon="play" wire:click="triggerTestSync">
{{ __('Test-Sync ausfuehren') }}
</flux:button>
</x-slot:actions>
</x-portal.page-header>
@if ($dryRunMessage)
<div class="px-4 py-3 rounded-[5px] border text-[12.5px] flex items-start gap-3
bg-[color:var(--color-hub-soft)] border-[color:var(--color-hub-soft-2)] text-[color:var(--color-ink-2)]">
<flux:icon.eye class="size-[16px] flex-shrink-0 mt-0.5 text-[color:var(--color-hub)]" />
<div class="flex-1">{{ $dryRunMessage }}</div>
</div>
@endif
@if ($syncMessage)
<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" />
{{ $syncMessage }}
</div>
@endif
{{-- ============== KPI-Reihe ============== --}}
<section class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<x-portal.stat-card variant="primary" :label="__('Gesamt')" :value="number_format($stats['total'])">
<x-slot:meta>{{ __('Subscriptions') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="ok" :label="__('Bestaetigt')" :value="number_format($stats['confirmed'])">
<x-slot:meta>{{ __('Double Opt-in') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="warn" :label="__('Unbestaetigt')" :value="number_format($stats['pending'])">
<x-slot:meta>{{ __('warten auf Opt-in') }}</x-slot:meta>
</x-portal.stat-card>
<x-portal.stat-card variant="muted" :label="__('Abgemeldet')" :value="number_format($stats['unsubscribed'])">
<x-slot:meta>{{ __('Unsubscribed') }}</x-slot:meta>
</x-portal.stat-card>
</section>
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Konfiguration') }}</span>
</div>
<dl class="p-5 grid grid-cols-1 gap-4 sm:grid-cols-2 text-[12.5px]">
<div>
<dt class="text-[10.5px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Provider') }}</dt>
<dd class="text-[color:var(--color-ink)] font-mono">{{ $syncConfig['provider'] }}</dd>
</div>
<div>
<dt class="text-[10.5px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Timeout') }}</dt>
<dd class="text-[color:var(--color-ink)] tabular-nums">{{ $syncConfig['timeout'] }}s</dd>
</div>
<div class="sm:col-span-2">
<dt class="text-[10.5px] uppercase tracking-[0.6px] text-[color:var(--color-ink-3)] font-semibold mb-1">{{ __('Endpoint') }}</dt>
<dd class="text-[color:var(--color-ink)] font-mono break-all">{{ $syncConfig['endpoint'] }}</dd>
</div>
</dl>
</article>
</div>