diff --git a/app/Console/Commands/AuditAdmins.php b/app/Console/Commands/AuditAdmins.php new file mode 100644 index 00000000..18f8811f --- /dev/null +++ b/app/Console/Commands/AuditAdmins.php @@ -0,0 +1,149 @@ + */ + private const CUSTOMER_ROLES = ['customer', 'api-only']; + + protected $signature = 'audit:admins + {--with-emails : Vollständige E-Mail-Adressen statt nur der Domain ausgeben}'; + + protected $description = 'Listet alle Konten mit Admin-, Editor-, Super-Admin- oder Sonderrechten als Markdown (Nachweis BN-02).'; + + public function handle(): int + { + $users = $this->privilegedUsers(); + $now = now(); + + $this->line(sprintf('# Berechtigungen, Stand %s', $now->format('d.m.Y H:i'))); + $this->newLine(); + $this->line($this->summary($users, $now)); + $this->newLine(); + $this->line('| ID | E-Mail | Rollen | Super-Admin | Aktiv | Gelöscht | Admin-Zugang | TOTP | Passkeys | Letzte Anmeldung | API-Tokens | Sitzungen zuletzt widerrufen |'); + $this->line('|---|---|---|---|---|---|---|---|---|---|---|---|'); + + foreach ($users as $user) { + $this->line('| '.implode(' | ', $this->row($user)).' |'); + } + + if ($users->isEmpty()) { + $this->line('| – | – | – | – | – | – | – | – | – | – | – | – |'); + } + + $this->newLine(); + $this->line('Admin-Zugang = aktiv und Super-Admin oder Rolle `admin`/`editor` (`User::canAccessAdmin()`). Sitzungen liegen in Redis und sind je Konto nicht zählbar; ein Widerruf beendet alle älteren Sitzungen.'); + $this->newLine(); + $this->line('**Zugänge außerhalb der Anwendung** (Ploi, Hetzner-Konsole, SSH): [eintragen]'); + + return self::SUCCESS; + } + + /** + * @return Collection + */ + private function privilegedUsers(): Collection + { + return User::query() + ->withTrashed() + ->with(['roles:id,name', 'permissions:id,name']) + ->withCount(['passkeys', 'tokens']) + ->withMax('tokens', 'last_used_at') + ->where(fn (Builder $query) => $query + ->where('is_super_admin', true) + ->orWhereHas('roles', fn (Builder $roles) => $roles->whereNotIn('name', self::CUSTOMER_ROLES)) + ->orWhereHas('permissions')) + ->orderBy('id') + ->get(); + } + + /** + * @param Collection $users + */ + private function summary(Collection $users, Carbon $now): string + { + $withAccess = $users->filter(fn (User $user): bool => ! $user->trashed() && $user->canAccessAdmin()); + $withSecondFactor = $withAccess->filter(fn (User $user): bool => $this->hasSecondFactor($user)); + $editors = $withAccess->filter(fn (User $user): bool => $user->roles->contains('name', 'editor')); + $superAdmins = $withAccess->where('is_super_admin', true); + + return sprintf( + 'Stand %s: %d %s mit Zugang zum Admin-Bereich, davon %d mit zweitem Faktor (TOTP oder Passkey), %d Super-Admin, %d Editor-%s. Gelistet sind %d Konten mit Rechten über den Kundenbereich hinaus, einschließlich gesperrter und gelöschter.', + $now->format('d.m.Y'), + $withAccess->count(), + $withAccess->count() === 1 ? 'Konto' : 'Konten', + $withSecondFactor->count(), + $superAdmins->count(), + $editors->count(), + $editors->count() === 1 ? 'Konto' : 'Konten', + $users->count(), + ); + } + + /** + * @return list + */ + private function row(User $user): array + { + $roles = $user->roles->pluck('name') + ->merge($user->permissions->pluck('name')->map(fn (string $name): string => 'direkt: '.$name)) + ->implode(', '); + $revokedAt = $user->sessions_revoked_at_ms + ? Carbon::createFromTimestampMs($user->sessions_revoked_at_ms)->timezone(config('app.timezone'))->format('d.m.Y H:i') + : '–'; + $tokenLastUsedAt = $user->getAttribute('tokens_max_last_used_at'); + $tokenLastUsed = $tokenLastUsedAt + ? ', zuletzt '.Carbon::parse($tokenLastUsedAt)->format('d.m.Y') + : ''; + + return [ + (string) $user->id, + $this->email($user), + $roles !== '' ? $roles : '–', + $user->is_super_admin ? 'ja' : 'nein', + $user->is_active ? 'ja' : 'gesperrt', + $user->trashed() ? $user->deleted_at->format('d.m.Y') : 'nein', + ! $user->trashed() && $user->canAccessAdmin() ? 'ja' : 'nein', + $user->two_factor_confirmed_at ? 'ja' : 'nein', + (string) $user->passkeys_count, + $user->last_login_at?->format('d.m.Y H:i') ?? '–', + $user->tokens_count.$tokenLastUsed, + $revokedAt, + ]; + } + + private function hasSecondFactor(User $user): bool + { + return $user->two_factor_confirmed_at !== null || $user->passkeys_count > 0; + } + + private function email(User $user): string + { + if ($this->option('with-emails')) { + return (string) $user->email; + } + + return '…@'.Str::after((string) $user->email, '@'); + } +} diff --git a/app/Enums/ActivityType.php b/app/Enums/ActivityType.php index ac3bd6f4..cf33a157 100644 --- a/app/Enums/ActivityType.php +++ b/app/Enums/ActivityType.php @@ -47,6 +47,9 @@ enum ActivityType: string /** Cloudflare Turnstile war nicht erreichbar — Formular durchgelassen, bitte nachprüfen (Spamschutz). */ case BotCheckUnavailable = 'bot_check_unavailable'; + /** Admin hat eine gebuchte Platzierung vorzeitig beendet. */ + case BoostEnded = 'boost_ended'; + public function label(): string { return match ($this) { @@ -77,6 +80,7 @@ enum ActivityType: string self::LoginLockout => 'Anmeldesperre', self::TwoFactorFailed => '2FA fehlgeschlagen', self::BotCheckUnavailable => 'Ohne Bot-Prüfung', + self::BoostEnded => 'Boost beendet', }; } @@ -107,6 +111,7 @@ enum ActivityType: string self::LoginFailed, self::TwoFactorFailed => 'orange', self::LoginLockout => 'red', self::BotCheckUnavailable => 'orange', + self::BoostEnded => 'amber', }; } } diff --git a/app/Services/PressRelease/BoostService.php b/app/Services/PressRelease/BoostService.php index c3c7b088..79209f92 100644 --- a/app/Services/PressRelease/BoostService.php +++ b/app/Services/PressRelease/BoostService.php @@ -2,6 +2,7 @@ namespace App\Services\PressRelease; +use App\Enums\ActivityType; use App\Enums\BoostPlacement; use App\Enums\Portal; use App\Enums\PressReleaseClassification; @@ -11,6 +12,7 @@ use App\Models\Boost; use App\Models\PressRelease; use App\Models\User; use App\Scopes\PortalScope; +use App\Services\ActivityLog; use App\Services\Billing\CreditPricingService; use App\Services\Billing\CreditWalletService; use App\Support\WebPageCache; @@ -35,6 +37,7 @@ class BoostService public function __construct( private readonly CreditPricingService $pricing, private readonly CreditWalletService $wallet, + private readonly ActivityLog $activityLog, ) {} /** @@ -180,6 +183,52 @@ class BoostService return $boost; } + /** + * Beendet alle laufenden und bereits angeschlossenen Boosts einer PM + * sofort (Admin ▸ Pressemitteilung), etwa nach einer Test-Buchung oder + * wenn die Platzierung nicht mehr vertretbar ist. Die Zeilen bleiben als + * Buchungsnachweis erhalten; Credits werden nicht zurückgebucht — + * Erstattungen laufen getrennt. + * + * @return int Anzahl der beendeten Boosts + */ + public function endActive(PressRelease $pressRelease, User $endedBy, string $reason): int + { + $now = now(); + $boosts = $pressRelease->boosts()->active()->get(); + + if ($boosts->isEmpty()) { + return 0; + } + + foreach ($boosts as $boost) { + $boost->update([ + 'starts_at' => $boost->starts_at->isFuture() ? $now : $boost->starts_at, + 'ends_at' => $now, + ]); + } + + $boosts->pluck('placement')->unique()->each( + fn (BoostPlacement $placement) => $this->forgetPlacementCaches($pressRelease, $placement), + ); + + $this->activityLog->record( + ActivityType::BoostEnded, + sprintf( + '%s hat %d gebuchte Platzierung(en) von PM #%d vorzeitig beendet. Grund: %s', + $endedBy->name, + $boosts->count(), + $pressRelease->id, + $reason, + ), + user: $endedBy, + subject: $pressRelease, + meta: ['boost_ids' => $boosts->modelKeys(), 'ended_by' => $endedBy->id, 'reason' => $reason], + ); + + return $boosts->count(); + } + /** * Serialisiert Käufe, die dasselbe sichtbare Slot-Inventar verändern. * diff --git a/resources/views/livewire/admin/press-releases/show.blade.php b/resources/views/livewire/admin/press-releases/show.blade.php index 387efe8b..624e27be 100644 --- a/resources/views/livewire/admin/press-releases/show.blade.php +++ b/resources/views/livewire/admin/press-releases/show.blade.php @@ -7,6 +7,7 @@ use App\Enums\AiImageDeclaration; use App\Services\PressRelease\ImageRightsConfirmationRequiredException; use App\Enums\ImagePrecheckCategory; use App\Services\Image\Precheck\ImagePrecheckReviewService; +use App\Services\PressRelease\BoostService; use App\Services\PressRelease\Images\ImageCorrectionService; use App\Services\PressRelease\PressReleaseService; use App\Services\PressRelease\QuotaExceededException; @@ -116,6 +117,31 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends Flux::modal('confirm-show-archive')->close(); } + public string $boostEndReason = ''; + + /** + * Gebuchte Platzierung vorzeitig beenden (Rechtspaket 10/2026, U10): + * etwa nach einem Test-Boost oder wenn die Platzierung nicht mehr + * vertretbar ist. Ohne Rückbuchung; nur mit Abrechnungsrecht. + */ + public function endBoost(BoostService $boosts): void + { + $this->authorize('billing:manage'); + + $this->validate([ + 'boostEndReason' => ['required', 'string', 'min:5', 'max:490'], + ]); + + $ended = $boosts->endActive($this->findAdminPR($this->id), auth()->user(), trim($this->boostEndReason)); + + $this->boostEndReason = ''; + + Flux::toast( + text: $ended > 0 ? __('Gebuchte Platzierung beendet.') : __('Kein laufender Boost.'), + variant: $ended > 0 ? 'success' : 'warning', + ); + } + /** * Korrekturweg (Bildkennzeichnung E.6): Bildnachweis oder * Portalkennzeichnung mit Pflichtgrund ändern, Bild vorläufig zurückhalten. @@ -275,6 +301,7 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends 'latestRejection' => $latestRejection, 'coverUrl' => $cover->coverUrl($pr, 'cover'), 'coverIsPlaceholder' => $cover->coverIsPlaceholder($pr), + 'activeBoosts' => $pr->boosts()->active()->orderBy('ends_at')->get(), 'categoryName' => $pr->category?->translations->firstWhere('locale', 'de')?->name ?? $pr->category?->translations->first()?->name ?? '–', @@ -489,6 +516,35 @@ new #[Layout('components.layouts.app'), Title('Pressemitteilung')] class extends @endif + {{-- ============== GEBUCHTE PLATZIERUNG (BOOST) ============== --}} + @if ($activeBoosts->isNotEmpty()) +
+
+ {{ __('Gebuchte Platzierung') }} + {{ __('läuft') }} +
+
+
    + @foreach ($activeBoosts as $activeBoost) +
  • + {{ $activeBoost->placement->label() }} · {{ __(':days Tage', ['days' => $activeBoost->days]) }} · {{ __(':credits Credits', ['credits' => $activeBoost->credits_charged]) }} + ({{ $activeBoost->starts_at->format('d.m.Y H:i') }} – {{ $activeBoost->ends_at->format('d.m.Y H:i') }}) +
  • + @endforeach +
+ @can('billing:manage') +

+ {{ __('Beenden nimmt die Meldung sofort von Start- und Branchenseite. Die Buchung bleibt als Nachweis erhalten; Credits werden nicht zurückgebucht.') }} +

+ +
+ {{ __('Boost beenden') }} +
+ @endcan +
+
+ @endif + {{-- ============== REJECTION-HINWEIS ============== --}} @if ($pr->status === \App\Enums\PressReleaseStatus::Rejected && $latestRejection)
diff --git a/tests/Feature/Admin/EndBoostTest.php b/tests/Feature/Admin/EndBoostTest.php new file mode 100644 index 00000000..1a26d8bb --- /dev/null +++ b/tests/Feature/Admin/EndBoostTest.php @@ -0,0 +1,91 @@ +seed(RolesAndPermissionsSeeder::class); + + $this->pressRelease = PressRelease::factory()->published()->create([ + 'classification' => PressReleaseClassification::Green->value, + ]); +}); + +test('an admin ends running and queued boosts with a reason', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(); + $admin->assignRole('admin'); + + $running = Boost::factory()->for($this->pressRelease)->create(); + $queued = Boost::factory()->for($this->pressRelease)->create([ + 'starts_at' => now()->addDays(7), + 'ends_at' => now()->addDays(14), + ]); + $expired = Boost::factory()->for($this->pressRelease)->expired()->create(); + $expiredEnd = $expired->ends_at; + + $this->actingAs($admin); + + Livewire::test('admin.press-releases.show', ['id' => $this->pressRelease->id]) + ->assertSee('Boost beenden') + ->set('boostEndReason', 'Test-Boost für die Screenshots beendet') + ->call('endBoost') + ->assertHasNoErrors(); + + expect($this->pressRelease->boosts()->active()->exists())->toBeFalse() + ->and($running->fresh()->ends_at->lessThanOrEqualTo(now()))->toBeTrue() + ->and($queued->fresh()->starts_at->lessThanOrEqualTo($queued->fresh()->ends_at))->toBeTrue() + ->and($expired->fresh()->ends_at->equalTo($expiredEnd))->toBeTrue() + ->and(Boost::query()->count())->toBe(3); + + $event = ActivityEvent::query()->where('type', ActivityType::BoostEnded->value)->sole(); + expect($event->description)->toContain('Test-Boost für die Screenshots beendet') + ->and($event->meta['boost_ids'])->toEqualCanonicalizing([$running->id, $queued->id]); +}); + +test('ending a boost requires a reason', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(); + $admin->assignRole('admin'); + Boost::factory()->for($this->pressRelease)->create(); + + $this->actingAs($admin); + + Livewire::test('admin.press-releases.show', ['id' => $this->pressRelease->id]) + ->set('boostEndReason', '') + ->call('endBoost') + ->assertHasErrors(['boostEndReason']); + + expect($this->pressRelease->boosts()->active()->exists())->toBeTrue(); +}); + +test('an editor sees the boost but cannot end it', function () { + /** @var TestCase $this */ + $editor = User::factory()->create(); + $editor->assignRole('editor'); + Boost::factory()->for($this->pressRelease)->create(); + + $this->actingAs($editor); + + Livewire::test('admin.press-releases.show', ['id' => $this->pressRelease->id]) + ->assertSee('Gebuchte Platzierung') + ->assertDontSee('Boost beenden') + ->set('boostEndReason', 'Versuch ohne Abrechnungsrecht') + ->call('endBoost') + ->assertForbidden(); + + expect($this->pressRelease->boosts()->active()->exists())->toBeTrue(); +}); diff --git a/tests/Feature/Auth/AdminAuditTest.php b/tests/Feature/Auth/AdminAuditTest.php new file mode 100644 index 00000000..a029b9ba --- /dev/null +++ b/tests/Feature/Auth/AdminAuditTest.php @@ -0,0 +1,69 @@ +seed(RolesAndPermissionsSeeder::class); +}); + +test('the audit lists admins, editors, super admins and locked or deleted privileged accounts', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(['email' => 'betrieb@pressekonto.test', 'last_login_at' => now()]); + $admin->assignRole('admin'); + DB::table('passkeys')->insert([ + 'user_id' => $admin->id, + 'name' => 'Laptop', + 'credential_id' => 'cred-1', + 'credential' => '{}', + 'created_at' => now(), + 'updated_at' => now(), + ]); + + $editor = User::factory()->create(['email' => 'redaktion@example.test']); + $editor->assignRole('editor'); + + $superAdmin = User::factory()->superAdmin()->create(['email' => 'alt@me.test']); + $superAdmin->assignRole('customer'); + $superAdmin->createToken('Legacy-API-Key (importiert)'); + + $locked = User::factory()->create(['email' => 'gesperrt@example.test', 'is_active' => false]); + $locked->assignRole('admin'); + + $deleted = User::factory()->create(['email' => 'geloescht@example.test']); + $deleted->assignRole('admin'); + $deleted->delete(); + + $customer = User::factory()->create(['email' => 'kunde@example.test']); + $customer->assignRole('customer'); + + $this->artisan('audit:admins') + ->expectsOutputToContain('3 Konten mit Zugang zum Admin-Bereich, davon 1 mit zweitem Faktor (TOTP oder Passkey), 1 Super-Admin, 1 Editor-Konto. Gelistet sind 5 Konten') + ->expectsOutputToContain('| '.$admin->id.' | …@pressekonto.test | admin | nein | ja | nein | ja | nein | 1 |') + ->expectsOutputToContain('| '.$superAdmin->id.' | …@me.test | customer | ja | ja | nein | ja |') + ->expectsOutputToContain('| '.$locked->id.' | …@example.test | admin | nein | gesperrt | nein | nein |') + ->expectsOutputToContain('| '.$deleted->id.' | …@example.test | admin | nein | ja | '.now()->format('d.m.Y').' | nein |') + ->doesntExpectOutputToContain('| '.$customer->id.' |') + ->doesntExpectOutputToContain('betrieb@pressekonto.test') + ->assertSuccessful(); + + expect($superAdmin->fresh()->tokens()->count())->toBe(1) + ->and($deleted->fresh()->trashed())->toBeTrue(); +}); + +test('the audit prints full email addresses only on request', function () { + /** @var TestCase $this */ + $admin = User::factory()->create(['email' => 'betrieb@pressekonto.test']); + $admin->assignRole('admin'); + + $this->artisan('audit:admins', ['--with-emails' => true]) + ->expectsOutputToContain('| '.$admin->id.' | betrieb@pressekonto.test | admin |') + ->assertSuccessful(); +});