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>
This commit is contained in:
Kevin Adametz 2026-07-14 13:48:50 +00:00
parent 39e13d6d84
commit 77f8f9cb35
34 changed files with 748 additions and 100 deletions

View file

@ -20,7 +20,8 @@ class UserImpersonation
return $this->canInitiate($admin)
&& ! $admin->is($target)
&& ! $this->isActive()
&& $this->canBeImpersonated($target);
&& $this->canBeImpersonated($target)
&& $this->mayImpersonateRank($admin, $target);
}
public function canBeImpersonated(User $target): bool
@ -28,6 +29,16 @@ class UserImpersonation
return $target->canAccessCustomer();
}
/**
* Rang-Schutz: Ein Super-Admin darf nur von einem Super-Admin im Panel
* angemeldet werden. Verhindert, dass ein Admin (users:manage) das mächtigere
* Super-Admin-Konto übernimmt und in dessen Namen z. B. API-Token erzeugt.
*/
public function mayImpersonateRank(User $admin, User $target): bool
{
return $admin->is_super_admin || ! $target->is_super_admin;
}
/**
* @throws AuthorizationException
*/
@ -49,6 +60,10 @@ class UserImpersonation
throw new AuthorizationException(__('Dieser Benutzer kann nicht im Panel angemeldet werden.'));
}
if (! $this->mayImpersonateRank($admin, $target)) {
throw new AuthorizationException(__('Super-Admins können nur von Super-Admins im Panel angemeldet werden.'));
}
session([self::SessionKey => $admin->getKey()]);
Auth::login($target);

View file

@ -0,0 +1,141 @@
<?php
namespace App\Authorization;
use App\Services\Admin\AdminSystemHealth;
/**
* Zentraler Katalog der Spatie-Permissions. Einzige Quelle für die
* Permission-Namen und die Rollen-Zuordnung — genutzt vom
* RolesAndPermissionsSeeder und von der Betriebsüberwachung
* ({@see AdminSystemHealth}). Blade-/Route-Gates verwenden
* weiterhin die String-Literale (Laravel-idiomatisch), deren kanonische
* Definition aber hier liegt.
*/
final class Permission
{
public const PressReleasesRead = 'press-releases:read';
public const PressReleasesWrite = 'press-releases:write';
public const PressReleasesPublish = 'press-releases:publish';
public const PressReleaseImagesWrite = 'press-release-images:write';
public const CompaniesRead = 'companies:read';
public const CompaniesWrite = 'companies:write';
public const CategoriesManage = 'categories:manage';
public const FooterCodesManage = 'footer-codes:manage';
public const BillingManage = 'billing:manage';
public const ComplianceManage = 'compliance:manage';
public const SettingsManage = 'settings:manage';
public const NewsletterSubscribe = 'newsletter:subscribe';
public const UsersManage = 'users:manage';
public const RolesManage = 'roles:manage';
public const ApiImmediatePublication = 'api:immediate-publication';
/**
* Alle existierenden Permissions.
*
* @return list<string>
*/
public static function all(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleasesPublish,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::CompaniesWrite,
self::CategoriesManage,
self::FooterCodesManage,
self::BillingManage,
self::ComplianceManage,
self::SettingsManage,
self::NewsletterSubscribe,
self::UsersManage,
self::RolesManage,
self::ApiImmediatePublication,
];
}
/**
* Nur an `admin` vergebene Verwaltungsrechte (Befund H2). Basis für den
* Deployment-Check „Berechtigungen ausgerollt".
*
* @return list<string>
*/
public static function adminOnly(): array
{
return [
self::CompaniesWrite,
self::CategoriesManage,
self::FooterCodesManage,
self::BillingManage,
self::ComplianceManage,
self::SettingsManage,
self::UsersManage,
self::RolesManage,
];
}
/**
* Permissions der Rolle `editor` (Content-Rolle ohne destruktive/Verwaltung).
*
* @return list<string>
*/
public static function editor(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleasesPublish,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
/**
* Permissions der Rolle `customer`.
*
* @return list<string>
*/
public static function customer(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
/**
* Permissions der Rolle `api-only`.
*
* @return list<string>
*/
public static function apiOnly(): array
{
return [
self::PressReleasesRead,
self::PressReleasesWrite,
self::PressReleaseImagesWrite,
self::CompaniesRead,
self::NewsletterSubscribe,
];
}
}

View file

@ -39,7 +39,8 @@ class User extends Authenticatable implements MustVerifyEmail
'registration_type',
'language',
'is_active',
'is_super_admin',
// 'is_super_admin' bewusst NICHT mass-assignable: nur über autorisiertes
// forceFill() setzbar (Super-Admin-Rang), sonst Privilege-Escalation-Landmine.
'last_login_at',
'last_login_ip',
'gdpr_consent_at',

View file

@ -30,6 +30,7 @@ use Illuminate\Auth\Events\Verified;
use Illuminate\Auth\Listeners\SendEmailVerificationNotification;
use Illuminate\Auth\Notifications\ResetPassword;
use Illuminate\Auth\Notifications\VerifyEmail;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Events\QueryExecuted;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Queue\Events\JobFailed;
@ -74,6 +75,8 @@ class AppServiceProvider extends ServiceProvider
*/
public function boot(): void
{
Model::preventLazyLoading(app()->isLocal());
$scheme = request()->header('X-Forwarded-Proto')
?? request()->server('HTTP_X_FORWARDED_PROTO')
?? (request()->secure() ? 'https' : 'http');
@ -91,6 +94,12 @@ class AppServiceProvider extends ServiceProvider
// Zugriffs-Gates als Single Source of Truth (auch in Blade via @can und in
// Policies nutzbar). Dieselbe Definition, die die Panel-Middleware prüfen.
// Super-Admins dürfen alles: zentraler Kurzschluss vor allen Gates und
// Permission-Checks (analog zu den Policy-`before()`-Methoden). Damit
// greifen feingranulare `can('users:manage')`-Prüfungen auch für
// Super-Admins, die keine Spatie-Rolle mit der Permission tragen.
Gate::before(fn (User $user, string $ability): ?bool => $user->is_super_admin ? true : null);
Gate::define('access-admin', fn (User $user): bool => $user->canAccessAdmin());
Gate::define('access-customer', fn (User $user): bool => $user->canAccessCustomer());

View file

@ -151,7 +151,7 @@ class UserImporter
$regType = self::REG_TYPE_MAP[$row->registration_type] ?? RegistrationType::ExistingLegacy;
$user = User::withoutTimestamps(function () use ($email, $name, $portal, $regType, $language, $row, $legacyPortal): User {
return User::query()->updateOrCreate(
$user = User::query()->updateOrCreate(
['email' => $email],
[
'name' => $name,
@ -159,7 +159,6 @@ class UserImporter
'registration_type' => $regType->value,
'language' => $language,
'is_active' => (bool) $row->is_active,
'is_super_admin' => (bool) $row->is_super_admin,
'last_login_at' => $row->last_login ?: null,
'last_login_ip' => $row->ip_address ?: null,
'legacy_portal' => $legacyPortal,
@ -169,6 +168,12 @@ class UserImporter
// Kein Passwort – User erhält Go-Live-Reset-Mail (D-09)
]
);
// is_super_admin ist nicht mass-assignable (siehe User::$fillable) –
// im vertrauenswürdigen Import explizit per forceFill setzen.
$user->forceFill(['is_super_admin' => (bool) $row->is_super_admin])->save();
return $user;
});
// Rolle zuweisen

View file

@ -2,6 +2,7 @@
namespace Database\Seeders;
use App\Authorization\Permission as PermissionCatalog;
use Illuminate\Database\Seeder;
use Spatie\Permission\Models\Permission;
use Spatie\Permission\Models\Role;
@ -24,48 +25,18 @@ class RolesAndPermissionsSeeder extends Seeder
Role::firstOrCreate(['name' => $role]);
}
$permissions = [
'press-releases:read',
'press-releases:write',
'press-releases:publish',
'press-release-images:write',
'companies:read',
'newsletter:subscribe',
'users:manage',
'roles:manage',
// Legacy-API-Parität (10.07.2026): Bestandskunden mit dem alten
// immediatePublication-Recht (connektar) veröffentlichen per
// /service-POST sofort — sofern der Admin-Schalter „KI-Prüfung
// Legacy-API" aus ist. Zuweisung je User (WS-7-Checkliste).
'api:immediate-publication',
];
// Kanonischer Permission-Katalog inkl. Rollen-Zuordnung: siehe
// App\Authorization\Permission. Schreibende CRM-/Verwaltungsrechte gehen
// nur an `admin` (Befund H2); der `editor` behält reine Content-Rechte.
$permissions = PermissionCatalog::all();
foreach ($permissions as $permission) {
Permission::firstOrCreate(['name' => $permission]);
}
Role::findByName('admin')->syncPermissions($permissions);
Role::findByName('editor')->syncPermissions([
'press-releases:read',
'press-releases:write',
'press-releases:publish',
'press-release-images:write',
'companies:read',
'newsletter:subscribe',
]);
Role::findByName('customer')->syncPermissions([
'press-releases:read',
'press-releases:write',
'press-release-images:write',
'companies:read',
'newsletter:subscribe',
]);
Role::findByName('api-only')->syncPermissions([
'press-releases:read',
'press-releases:write',
'press-release-images:write',
'companies:read',
'newsletter:subscribe',
]);
Role::findByName('editor')->syncPermissions(PermissionCatalog::editor());
Role::findByName('customer')->syncPermissions(PermissionCatalog::customer());
Role::findByName('api-only')->syncPermissions(PermissionCatalog::apiOnly());
}
}

View file

@ -40,6 +40,7 @@
? app(\App\Services\Admin\AdminPerformanceCache::class)->pressReleaseReviewCount()
: 0;
$legalOpenCount = $canAdmin ? \App\Models\LegalRequest::query()->open()->count() : 0;
$contactOpenCount = $canAdmin ? \App\Models\ContactMessage::query()->open()->count() : 0;
@endphp
<flux:navlist variant="outline">
@ -115,14 +116,18 @@
:badge="$reviewCount > 0 ? $reviewCount : null" badge-color="yellow" wire:navigate>
{{ __('Pressemitteilungen') }}
</flux:navlist.item>
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
:current="request()->routeIs('admin.categories.*')" wire:navigate>
{{ __('Kategorien') }}
</flux:navlist.item>
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')"
:current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
{{ __('Footer-Codes') }}
</flux:navlist.item>
@can('categories:manage')
<flux:navlist.item icon="folder" :href="route('admin.categories.index')"
:current="request()->routeIs('admin.categories.*')" wire:navigate>
{{ __('Kategorien') }}
</flux:navlist.item>
@endcan
@can('footer-codes:manage')
<flux:navlist.item icon="code-bracket-square" :href="route('admin.footer-codes.index')"
:current="request()->routeIs('admin.footer-codes.*')" wire:navigate>
{{ __('Footer-Codes') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
{{-- CRM --}}
@ -137,16 +142,28 @@
</flux:navlist.item>
</flux:navlist.group>
{{-- Recht & Compliance --}}
<flux:navlist.group :heading="__('Recht & Compliance')" class="grid mb-4">
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
:current="request()->routeIs('admin.legal-requests.*')"
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anfragen') }}
</flux:navlist.item>
</flux:navlist.group>
@can('compliance:manage')
{{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}}
<flux:navlist.group :heading="__('Kontakt')" class="grid mb-4">
<flux:navlist.item icon="chat-bubble-left-right" :href="route('admin.contact-messages.index')"
:current="request()->routeIs('admin.contact-messages.*')"
:badge="$contactOpenCount > 0 ? $contactOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anliegen') }}
</flux:navlist.item>
</flux:navlist.group>
{{-- Recht & Compliance --}}
<flux:navlist.group :heading="__('Recht & Compliance')" class="grid mb-4">
<flux:navlist.item icon="scale" :href="route('admin.legal-requests.index')"
:current="request()->routeIs('admin.legal-requests.*')"
:badge="$legalOpenCount > 0 ? $legalOpenCount : null" badge-color="red" wire:navigate>
{{ __('Anfragen') }}
</flux:navlist.item>
</flux:navlist.group>
@endcan
{{-- Billing --}}
@can('billing:manage')
<flux:navlist.group :heading="__('Billing')" class="grid mb-4">
<flux:navlist.item icon="archive-box" :href="route('admin.invoices.index')"
:current="request()->routeIs('admin.invoices.*')" wire:navigate>
@ -169,6 +186,7 @@
{{ __('Newsletter Sync') }}
</flux:navlist.item>
</flux:navlist.group>
@endcan
{{-- Administration --}}
<flux:navlist.group :heading="__('Administration')" class="grid mb-4">
@ -176,22 +194,32 @@
:current="request()->routeIs('admin.presets.*')" wire:navigate>
{{ __('Voreinstellungen') }}
</flux:navlist.item>
<flux:navlist.item icon="users" :href="route('admin.users.index')"
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
:current="request()->routeIs('admin.roles.*')" wire:navigate>
{{ __('Rollen & Rechte') }}
</flux:navlist.item>
@can('users:manage')
<flux:navlist.item icon="users" :href="route('admin.users.index')"
:current="request()->routeIs('admin.users.*')" wire:navigate>
{{ __('Benutzer') }}
</flux:navlist.item>
@endcan
@can('roles:manage')
<flux:navlist.item icon="shield-check" :href="route('admin.roles.index')"
:current="request()->routeIs('admin.roles.*')" wire:navigate>
{{ __('Rollen & Rechte') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
{{-- Reports --}}
<flux:navlist.group :heading="__('Reports')" class="grid mb-4">
<flux:navlist.item icon="chart-bar-square" :href="route('admin.reports.slow-requests')"
:current="request()->routeIs('admin.reports.*')" wire:navigate>
:current="request()->routeIs('admin.reports.slow-requests')" wire:navigate>
{{ __('Performance') }}
</flux:navlist.item>
@can('settings:manage')
<flux:navlist.item icon="heart" :href="route('admin.reports.system-health')"
:current="request()->routeIs('admin.reports.system-health')" wire:navigate>
{{ __('Betriebsüberwachung') }}
</flux:navlist.item>
@endcan
</flux:navlist.group>
@endif
@ -387,6 +415,9 @@
{{ $slot }}
{{-- Schwebendes Kontakt-Widget (unten rechts, eingeloggter Bereich) --}}
<x-web.contact-launcher />
@persist('toast')
<flux:toast position="top end" class="pt-24" />
@endpersist

View file

@ -50,6 +50,8 @@ new #[Layout('components.layouts.app'), Title('Kategorie anlegen')] class extend
public function save(): void
{
$this->authorize('categories:manage');
$this->validate([
'slugDe' => ['nullable', 'string', 'max:120'],
'slugEn' => ['nullable', 'string', 'max:120'],

View file

@ -63,6 +63,8 @@ new #[Layout('components.layouts.app'), Title('Neue Firma')] class extends Compo
public function save(ImageService $imageService): void
{
$this->authorize('companies:write');
$this->validate();
$slug = (new Company)->generateUniqueSlug($this->company_name, ['portal' => $this->portal]);

View file

@ -103,6 +103,8 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
public function update(ImageService $imageService): void
{
$this->authorize('companies:write');
$this->validate();
$company = Company::query()->find($this->companyId);
@ -186,6 +188,8 @@ new #[Layout('components.layouts.app'), Title('Firma bearbeiten')] class extends
public function deleteCompany(): void
{
$this->authorize('companies:write');
$company = Company::query()->find($this->companyId);
if (! $company) {
session()->flash('error', __('Die angeforderte Firma wurde nicht gefunden.'));

View file

@ -69,6 +69,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt anlegen')] class extends
public function save(): void
{
$this->authorize('companies:write');
$validated = $this->validate([
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],

View file

@ -58,6 +58,8 @@ new #[Layout('components.layouts.app'), Title('Kontakt bearbeiten')] class exten
public function save(): void
{
$this->authorize('companies:write');
$validated = $this->validate([
'companyId' => ['required', 'integer', Rule::exists('companies', 'id')],
'portal' => ['required', Rule::in(array_map(static fn (Portal $portal): string => $portal->value, Portal::cases()))],

View file

@ -350,6 +350,8 @@ new #[Layout('components.layouts.app'), Title('Kontakte')] class extends Compone
public function deleteContactFromIndex(int $contactId): void
{
$this->authorize('companies:write');
$contact = Contact::query()->find($contactId);
if (! $contact) {
$this->notification = __('Der angeforderte Kontakt wurde nicht gefunden.');

View file

@ -35,6 +35,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Code anlegen')] class exte
public function save(): void
{
$this->authorize('footer-codes:manage');
$this->validate();
DB::transaction(function (): void {

View file

@ -54,6 +54,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
public function save(): void
{
$this->authorize('footer-codes:manage');
$this->validate();
$code = FooterCode::query()->findOrFail($this->id);
@ -81,6 +83,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Code bearbeiten')] class e
public function delete(): void
{
$this->authorize('footer-codes:manage');
$code = FooterCode::query()->findOrFail($this->id);
$code->delete();

View file

@ -34,6 +34,8 @@ new #[Layout('components.layouts.app'), Title('Footer-Codes')] class extends Com
public function toggleActive(int $id): void
{
$this->authorize('footer-codes:manage');
$footerCode = FooterCode::query()->find($id);
if (! $footerCode) {

View file

@ -52,6 +52,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
*/
public function resolveWithdrawal(int $id): void
{
$this->authorize('compliance:manage');
WithdrawalRequest::query()->findOrFail($id)->update([
'status' => 'resolved',
'resolved_at' => now(),
@ -60,6 +62,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
public function reopenWithdrawal(int $id): void
{
$this->authorize('compliance:manage');
WithdrawalRequest::query()->findOrFail($id)->update([
'status' => 'open',
'resolved_at' => null,
@ -72,6 +76,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
*/
public function resolveTermination(int $id): void
{
$this->authorize('compliance:manage');
TerminationRequest::query()->findOrFail($id)->update([
'status' => 'resolved',
'resolved_at' => now(),
@ -80,6 +86,8 @@ new #[Layout('components.layouts.app'), Title('Recht & Compliance')] class exten
public function reopenTermination(int $id): void
{
$this->authorize('compliance:manage');
TerminationRequest::query()->findOrFail($id)->update([
'status' => 'open',
'resolved_at' => null,

View file

@ -24,22 +24,30 @@ new #[Layout('components.layouts.app'), Title('Anfrage · Recht & Compliance')]
public function markInProgress(): void
{
$this->authorize('compliance:manage');
$this->request()->update(['status' => LegalRequestStatus::InProgress->value]);
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
}
public function resolve(): void
{
$this->authorize('compliance:manage');
$this->finish(LegalRequestStatus::Resolved, __('Anfrage als erledigt markiert.'));
}
public function reject(): void
{
$this->authorize('compliance:manage');
$this->finish(LegalRequestStatus::Rejected, __('Anfrage abgelehnt.'));
}
public function reopen(): void
{
$this->authorize('compliance:manage');
$this->request()->update([
'status' => LegalRequestStatus::Open->value,
'resolved_by_user_id' => null,

View file

@ -15,6 +15,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends
public function triggerDryRun(): void
{
$this->authorize('billing:manage');
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {
@ -32,6 +34,8 @@ new #[Layout('components.layouts.app'), Title('Newsletter Sync')] class extends
public function triggerTestSync(): void
{
$this->authorize('billing:manage');
$subscription = NewsletterSubscription::query()->latest('id')->first();
if ($subscription === null) {

View file

@ -39,6 +39,8 @@ new #[Layout('components.layouts.app'), Title('Zahlungen')] class extends Compon
*/
public function cancelBundle(int $userId, LegacySubscriptionService $legacy): void
{
$this->authorize('billing:manage');
$user = User::query()->find($userId);
if (! $user) {

View file

@ -31,6 +31,8 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
public function edit(int $planId): void
{
$this->authorize('billing:manage');
$plan = Plan::query()->findOrFail($planId);
$this->editingPlanId = $plan->id;
@ -49,6 +51,8 @@ new #[Layout('components.layouts.app'), Title('Tarife & Pakete')] class extends
public function save(StripePlanSyncService $stripeSync): void
{
$this->authorize('billing:manage');
// Deutsche Dezimal-Eingaben (49,00) für die numeric-Regel normalisieren.
$this->monthlyPrice = str_replace(',', '.', trim($this->monthlyPrice));
$this->yearlyPrice = str_replace(',', '.', trim($this->yearlyPrice));

View file

@ -19,8 +19,15 @@ new #[Layout('components.layouts.app'), Title('Neue Rolle')] class extends Compo
public string $guardName = 'web';
public function mount(): void
{
$this->authorize('roles:manage');
}
public function save(): void
{
$this->authorize('roles:manage');
$validated = $this->validate([
'name' => [
'required',

View file

@ -27,6 +27,8 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
public function mount(int $id): void
{
$this->authorize('roles:manage');
$this->id = $id;
$role = Role::query()
@ -44,6 +46,8 @@ new #[Layout('components.layouts.app'), Title('Rolle bearbeiten')] class extends
public function save(): void
{
$this->authorize('roles:manage');
$validated = $this->validate([
'name' => [
'required',

View file

@ -7,6 +7,11 @@ use Spatie\Permission\Models\Role;
new #[Layout('components.layouts.app'), Title('Rollen & Rechte')] class extends Component
{
public function mount(): void
{
$this->authorize('roles:manage');
}
public function with(): array
{
return [

View file

@ -37,6 +37,11 @@ new #[Layout('components.layouts.app'), Title('Benutzer')] class extends Compone
public ?int $viewingUserId = null;
public function mount(): void
{
$this->authorize('users:manage');
}
public function with(): array
{
$sortable = ['name', 'email', 'portal', 'is_active', 'last_login_at', 'created_at'];

View file

@ -70,6 +70,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
public function mount(): void
{
$this->authorize('users:manage');
$this->portal = Portal::Both->value;
$this->registrationType = RegistrationType::ExistingLegacy->value;
}
@ -110,6 +112,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
public function save(): void
{
$this->authorize('users:manage');
$validated = $this->validate([
...$this->userIdentityRules(),
...$this->userRolesAndCompaniesRules(),
@ -124,10 +128,15 @@ new #[Layout('components.layouts.app'), Title('Benutzer anlegen')] class extends
'registration_type' => $validated['registrationType'],
'language' => $validated['language'],
'is_active' => $this->isActive,
'is_super_admin' => $this->isSuperAdmin,
'password' => null,
]);
// Super-Admin-Rang darf nur ein Super-Admin vergeben (is_super_admin ist
// nicht mass-assignable, siehe User::$fillable).
if ($this->isSuperAdmin && auth()->user()->is_super_admin) {
$user->forceFill(['is_super_admin' => true])->save();
}
$user->syncRoles($validated['selectedRoles']);
$user->syncCompaniesWithRoles($validated['linkedCompanyIds'], $validated['companyRoles']);

View file

@ -136,8 +136,21 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
'country_code' => null,
];
/**
* Verhindert, dass ein Nicht-Super-Admin (nur `users:manage`) ein
* mächtigeres Super-Admin-Konto bearbeitet — sonst ließe sich über einen
* E-Mail-/Rollen-/Aktiv-Wechsel das Konto übernehmen (analog zur
* Impersonation-Rangprüfung). Nur ein Super-Admin darf Super-Admins ändern.
*/
private function assertMayManageRank(User $user): void
{
abort_if($user->is_super_admin && ! auth()->user()->is_super_admin, 403);
}
public function mount(int $id): void
{
$this->authorize('users:manage');
$this->id = $id;
$user = User::query()
@ -150,6 +163,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
])
->findOrFail($id);
$this->assertMayManageRank($user);
$this->name = $user->name;
$this->agencyName = (string) ($user->agency_name ?? '');
$this->email = $user->email;
@ -379,6 +394,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
public function save(): void
{
$this->authorize('users:manage');
$validated = $this->validate([
...$this->userIdentityRules($this->id),
'profile.salutation_key' => ['nullable', 'string', 'max:20'],
@ -411,6 +428,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
$user = User::query()->findOrFail($this->id);
$this->assertMayManageRank($user);
$user->update([
'name' => $validated['name'],
'agency_name' => $validated['agencyName'] ?: null,
@ -419,9 +438,14 @@ new #[Layout('components.layouts.app'), Title('Benutzer bearbeiten')] class exte
'registration_type' => $validated['registrationType'],
'language' => $validated['language'],
'is_active' => $this->isActive,
'is_super_admin' => $this->isSuperAdmin,
]);
// Super-Admin-Rang darf nur ein Super-Admin vergeben/entziehen. is_super_admin
// ist nicht mass-assignable und wird ausschließlich hier autorisiert gesetzt.
if (auth()->user()->is_super_admin) {
$user->forceFill(['is_super_admin' => $this->isSuperAdmin])->save();
}
$user->syncRoles($validated['selectedRoles']);
$profilePayload = [

View file

@ -13,6 +13,8 @@ new #[Layout('components.layouts.app'), Title('Benutzer anzeigen')] class extend
public function mount(int $id): void
{
$this->authorize('users:manage');
$this->id = $id;
if (! User::query()->whereKey($id)->exists()) {

View file

@ -1,5 +1,6 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Services\Api\ApiAccessEligibilityService;
use Illuminate\Validation\Rule;
use Livewire\Attributes\Layout;
@ -19,6 +20,8 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
public ?string $eligibilityMessage = null;
private const IMPERSONATION_DENIAL_MESSAGE = 'Während der Benutzer-Impersonation können keine API-Tokens erstellt oder widerrufen werden.';
private const ABILITIES = [
'press-releases:read' => 'Pressemitteilungen lesen',
'press-releases:write' => 'Pressemitteilungen erstellen und bearbeiten',
@ -29,6 +32,8 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
public function createToken(): void
{
$this->ensureNotImpersonating();
$eligibility = app(ApiAccessEligibilityService::class);
$denialReason = $eligibility->denialReason(auth()->user());
@ -59,6 +64,8 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
public function revokeToken(int $tokenId): void
{
$this->ensureNotImpersonating();
auth()->user()
->tokens()
->whereKey($tokenId)
@ -72,17 +79,30 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
{
$eligibility = app(ApiAccessEligibilityService::class);
$denialReason = $eligibility->denialReason(auth()->user());
$isImpersonating = app(UserImpersonation::class)->isActive();
return [
'abilityOptions' => self::ABILITIES,
'canCreateApiToken' => $denialReason === null,
'apiTokenDenialReason' => $denialReason,
'canCreateApiToken' => $denialReason === null && ! $isImpersonating,
'canRevokeApiTokens' => ! $isImpersonating,
'apiTokenDenialReason' => $isImpersonating
? __(self::IMPERSONATION_DENIAL_MESSAGE)
: $denialReason,
'tokens' => auth()->user()
->tokens()
->latest()
->get(['id', 'name', 'abilities', 'last_used_at', 'created_at']),
];
}
private function ensureNotImpersonating(): void
{
abort_if(
app(UserImpersonation::class)->isActive(),
403,
__(self::IMPERSONATION_DENIAL_MESSAGE),
);
}
}; ?>
<div class="space-y-8">
@ -223,6 +243,7 @@ new #[Layout('components.layouts.app'), Title('API-Tokens')] class extends Compo
icon="trash"
wire:click="revokeToken({{ $token->id }})"
wire:confirm="{{ __('Diesen API-Token wirklich widerrufen?') }}"
:disabled="! $canRevokeApiTokens"
>
{{ __('Widerrufen') }}
</flux:button>

View file

@ -42,41 +42,54 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
Volt::route('admin/press-releases/{id}', 'admin.press-releases.show')->name('admin.press-releases.show');
Volt::route('admin/press-releases/{id}/edit', 'admin.press-releases.edit')->name('admin.press-releases.edit');
Volt::route('admin/categories', 'admin.categories.index')->name('admin.categories.index');
Volt::route('admin/categories/create', 'admin.categories.create')->name('admin.categories.create');
Volt::route('admin/categories/{id}/edit', 'admin.categories.edit')->name('admin.categories.edit');
// Rubriken-Taxonomie: portalweite Struktur — nur admin (Befund H2).
Route::middleware('can:categories:manage')->group(function (): void {
Volt::route('admin/categories', 'admin.categories.index')->name('admin.categories.index');
Volt::route('admin/categories/create', 'admin.categories.create')->name('admin.categories.create');
Volt::route('admin/categories/{id}/edit', 'admin.categories.edit')->name('admin.categories.edit');
});
Volt::route('admin/footer-codes', 'admin.footer-codes.index')->name('admin.footer-codes.index');
Volt::route('admin/footer-codes/create', 'admin.footer-codes.create')->name('admin.footer-codes.create');
Volt::route('admin/footer-codes/{id}/edit', 'admin.footer-codes.edit')->name('admin.footer-codes.edit');
Route::middleware('can:footer-codes:manage')->group(function (): void {
Volt::route('admin/footer-codes', 'admin.footer-codes.index')->name('admin.footer-codes.index');
Volt::route('admin/footer-codes/create', 'admin.footer-codes.create')->name('admin.footer-codes.create');
Volt::route('admin/footer-codes/{id}/edit', 'admin.footer-codes.edit')->name('admin.footer-codes.edit');
});
// CRM
// Lesen (companies:read) auch für Editoren; Schreiben nur admin (companies:write, Befund H2).
Volt::route('admin/companies', 'admin.companies.index')->name('admin.companies.index');
Volt::route('admin/companies/create', 'admin.companies.create')->name('admin.companies.create');
Volt::route('admin/companies/{id}', 'admin.companies.show')->name('admin.companies.show');
Volt::route('admin/companies/{id}/edit', 'admin.companies.edit')->name('admin.companies.edit');
Volt::route('admin/companies/{companyId}/contacts/create', 'admin.contacts.create')->name('admin.companies.contacts.create');
Volt::route('admin/companies/{id}', 'admin.companies.show')->where('id', '[0-9]+')->name('admin.companies.show');
Volt::route('admin/contacts', 'admin.contacts.index')->name('admin.contacts.index');
Volt::route('admin/contacts/create', 'admin.contacts.create')->name('admin.contacts.create');
Volt::route('admin/contacts/{id}/edit', 'admin.contacts.edit')->name('admin.contacts.edit');
Route::middleware('can:companies:write')->group(function (): void {
Volt::route('admin/companies/create', 'admin.companies.create')->name('admin.companies.create');
Volt::route('admin/companies/{id}/edit', 'admin.companies.edit')->name('admin.companies.edit');
Volt::route('admin/companies/{companyId}/contacts/create', 'admin.contacts.create')->name('admin.companies.contacts.create');
Volt::route('admin/contacts/create', 'admin.contacts.create')->name('admin.contacts.create');
Volt::route('admin/contacts/{id}/edit', 'admin.contacts.edit')->name('admin.contacts.edit');
});
// Recht & Compliance (WS-3): Queue für DSGVO-/Persönlichkeitsrechts-/Meldungen
Volt::route('admin/legal-requests', 'admin.legal-requests.index')->name('admin.legal-requests.index');
Volt::route('admin/legal-requests/{id}', 'admin.legal-requests.show')->name('admin.legal-requests.show');
// — sensible Vorgänge, nur admin (Befund H2).
Route::middleware('can:compliance:manage')->group(function (): void {
Volt::route('admin/legal-requests', 'admin.legal-requests.index')->name('admin.legal-requests.index');
Volt::route('admin/legal-requests/{id}', 'admin.legal-requests.show')->name('admin.legal-requests.show');
// Kontakt: Queue für Anliegen aus dem Kontakt-Widget (Kontaktaufnahme,
// Fehlermeldung, PM-Meldung, Feedback)
Volt::route('admin/contact-messages', 'admin.contact-messages.index')->name('admin.contact-messages.index');
Volt::route('admin/contact-messages/{id}', 'admin.contact-messages.show')->name('admin.contact-messages.show');
// Kontakt: Queue für Anliegen aus dem Kontakt-Widget (Kontaktaufnahme,
// Fehlermeldung, PM-Meldung, Feedback)
Volt::route('admin/contact-messages', 'admin.contact-messages.index')->name('admin.contact-messages.index');
Volt::route('admin/contact-messages/{id}', 'admin.contact-messages.show')->name('admin.contact-messages.show');
});
// Billing
Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index');
Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf');
Volt::route('admin/payments', 'admin.payments.index')->name('admin.payments.index');
Volt::route('admin/payments/plans', 'admin.payments.plans')->name('admin.payments.plans');
Volt::route('admin/coupons', 'admin.coupons.index')->name('admin.coupons.index');
Volt::route('admin/newsletter-sync', 'admin.newsletter.sync')->name('admin.newsletter.sync');
// Billing: Rechnungen, Zahlungen, Tarife, Gutscheine — nur admin (Befund H2).
Route::middleware('can:billing:manage')->group(function (): void {
Volt::route('admin/invoices', 'admin.invoices.index')->name('admin.invoices.index');
Route::get('admin/legacy-invoices/{legacyInvoice}/pdf', LegacyInvoicePdfController::class)->name('admin.legacy-invoices.pdf');
Volt::route('admin/payments', 'admin.payments.index')->name('admin.payments.index');
Volt::route('admin/payments/plans', 'admin.payments.plans')->name('admin.payments.plans');
Volt::route('admin/coupons', 'admin.coupons.index')->name('admin.coupons.index');
Volt::route('admin/newsletter-sync', 'admin.newsletter.sync')->name('admin.newsletter.sync');
});
// Administration
Volt::route('admin/presets', 'admin.presets.index')->name('admin.presets.index');
@ -94,4 +107,8 @@ Route::middleware(['auth', 'verified', BlockAdminDuringImpersonation::class, Ens
// Reports
Volt::route('admin/reports/slow-requests', 'admin.reports.slow-requests')->name('admin.reports.slow-requests');
Route::middleware('can:settings:manage')->group(function (): void {
Volt::route('admin/reports/system-health', 'admin.reports.system-health')->name('admin.reports.system-health');
});
});

View file

@ -0,0 +1,134 @@
<?php
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Regression zu Befund K1/M8 (Audit 14.07.2026):
* Die Rolle `editor` hat Admin-Panel-Zugriff (`access-admin`), aber bewusst
* NICHT `users:manage`/`roles:manage`. Die Benutzer-/Rollenverwaltung darf
* daher für sie gesperrt sein, und `is_super_admin` darf nur ein Super-Admin
* vergeben.
*/
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
});
test('editor cannot open user or role management pages', function () {
/** @var TestCase $this */
$editor = User::factory()->create();
$editor->assignRole('editor');
$target = User::factory()->create();
$target->assignRole('customer');
$this->actingAs($editor);
foreach ([
'https://pressekonto.test/admin/users',
'https://pressekonto.test/admin/users/create',
"https://pressekonto.test/admin/users/{$target->id}",
"https://pressekonto.test/admin/users/{$target->id}/edit",
'https://pressekonto.test/admin/roles',
'https://pressekonto.test/admin/roles/create',
] as $url) {
$this->get($url)->assertForbidden();
}
});
test('admin can open user and role management pages', function () {
/** @var TestCase $this */
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin);
$this->get('https://pressekonto.test/admin/users')->assertSuccessful();
$this->get('https://pressekonto.test/admin/roles')->assertSuccessful();
});
test('editor cannot mutate roles via the roles component', function () {
/** @var TestCase $this */
$editor = User::factory()->create();
$editor->assignRole('editor');
$this->actingAs($editor);
LivewireVolt::test('admin.roles.create')->assertForbidden();
});
test('non super admin cannot elevate a user to super admin', function () {
/** @var TestCase $this */
$admin = User::factory()->create();
$admin->assignRole('admin'); // hat users:manage, ist aber kein Super-Admin
$target = User::factory()->create(['email' => 'target@example.com']);
$target->assignRole('customer');
$this->actingAs($admin);
LivewireVolt::test('admin.users.edit', ['id' => $target->id])
->set('name', 'Ziel Benutzer')
->set('email', 'target@example.com')
->set('selectedRoles', ['customer'])
->set('isSuperAdmin', true)
->call('save')
->assertHasNoErrors();
expect($target->fresh()->is_super_admin)->toBeFalse();
});
test('super admin can elevate a user to super admin', function () {
/** @var TestCase $this */
$superAdmin = User::factory()->superAdmin()->create();
$target = User::factory()->create(['email' => 'target@example.com']);
$target->assignRole('customer');
$this->actingAs($superAdmin);
LivewireVolt::test('admin.users.edit', ['id' => $target->id])
->set('name', 'Ziel Benutzer')
->set('email', 'target@example.com')
->set('selectedRoles', ['customer'])
->set('isSuperAdmin', true)
->call('save')
->assertHasNoErrors();
expect($target->fresh()->is_super_admin)->toBeTrue();
});
test('non super admin cannot open the edit page of a super admin', function () {
/** @var TestCase $this */
$admin = User::factory()->create();
$admin->assignRole('admin'); // users:manage, aber kein Super-Admin
$superAdmin = User::factory()->superAdmin()->create();
$this->actingAs($admin);
$this->get("https://pressekonto.test/admin/users/{$superAdmin->id}/edit")->assertForbidden();
});
test('super admin can still edit another super admin', function () {
/** @var TestCase $this */
$superAdmin = User::factory()->superAdmin()->create();
$target = User::factory()->superAdmin()->create(['email' => 'peer@example.com']);
$this->actingAs($superAdmin);
$this->get("https://pressekonto.test/admin/users/{$target->id}/edit")->assertSuccessful();
});
test('is_super_admin is not mass assignable', function () {
/** @var TestCase $this */
$user = User::query()->create([
'name' => 'Massenzuweisung',
'email' => 'mass@example.com',
'is_super_admin' => true,
]);
expect($user->fresh()->is_super_admin)->toBeFalse();
});

View file

@ -0,0 +1,124 @@
<?php
use App\Http\Controllers\Api\Legacy\LegacyPressReleaseController;
use App\Models\AppSetting;
use App\Models\Company;
use App\Models\Contact;
use App\Models\User;
use Database\Seeders\RolesAndPermissionsSeeder;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Regression zu Befund H2 (Audit 14.07.2026): Die Rolle `editor` behält ihre
* Content-Rechte (PMs), darf aber keine destruktiven CRM-/Billing-/Compliance-/
* Struktur-Aktionen ausführen. Gekoppelt an die neuen Seeder-Permissions
* companies:write / categories:manage / footer-codes:manage / billing:manage /
* compliance:manage.
*/
beforeEach(function () {
$this->seed(RolesAndPermissionsSeeder::class);
});
function editorActor(): User
{
$editor = User::factory()->create();
$editor->assignRole('editor');
return $editor;
}
test('editor is blocked from admin-only management pages', function () {
/** @var TestCase $this */
$this->actingAs(editorActor());
foreach ([
'admin/categories',
'admin/categories/create',
'admin/footer-codes',
'admin/footer-codes/create',
'admin/payments',
'admin/coupons',
'admin/invoices',
'admin/legal-requests',
'admin/contact-messages',
] as $path) {
$this->get("https://pressekonto.test/{$path}")->assertForbidden();
}
});
test('editor keeps access to content and read-only CRM pages', function () {
/** @var TestCase $this */
$this->actingAs(editorActor());
$this->get('https://pressekonto.test/admin/press-releases')->assertSuccessful();
$this->get('https://pressekonto.test/admin/companies')->assertSuccessful();
$this->get('https://pressekonto.test/admin/contacts')->assertSuccessful();
});
test('editor cannot open company write forms', function () {
/** @var TestCase $this */
$company = Company::factory()->presseecho()->create();
$this->actingAs(editorActor());
$this->get('https://pressekonto.test/admin/companies/create')->assertForbidden();
$this->get("https://pressekonto.test/admin/companies/{$company->id}/edit")->assertForbidden();
});
test('editor cannot delete a contact from the index', function () {
/** @var TestCase $this */
$company = Company::factory()->presseecho()->create();
$contact = Contact::factory()->for($company)->create();
$this->actingAs(editorActor());
LivewireVolt::test('admin.contacts.index')
->call('deleteContactFromIndex', $contact->id)
->assertForbidden();
expect(Contact::query()->whereKey($contact->id)->exists())->toBeTrue();
});
test('editor cannot invoke admin-only livewire actions directly', function () {
/** @var TestCase $this */
$this->actingAs(editorActor());
LivewireVolt::test('admin.categories.create')
->call('save')
->assertForbidden();
LivewireVolt::test('admin.footer-codes.create')
->call('save')
->assertForbidden();
LivewireVolt::test('admin.payments.index')
->call('cancelBundle', 999_999)
->assertForbidden();
LivewireVolt::test('admin.legal-requests.index')
->call('resolveWithdrawal', 999_999)
->assertForbidden();
LivewireVolt::test('admin.press-releases.index')
->set('legacyApiAiReview', true)
->assertForbidden();
expect(AppSetting::getBool(LegacyPressReleaseController::SETTING_AI_REVIEW))->toBeFalse();
});
test('admin retains access to all restricted management pages', function () {
/** @var TestCase $this */
$admin = User::factory()->create();
$admin->assignRole('admin');
$this->actingAs($admin);
foreach ([
'admin/categories',
'admin/footer-codes',
'admin/invoices',
'admin/legal-requests',
'admin/contact-messages',
] as $path) {
$this->get("https://pressekonto.test/{$path}")->assertSuccessful();
}
});

View file

@ -45,10 +45,9 @@ test('users without manage permission cannot start impersonation', function () {
$this->actingAs($editor);
LivewireVolt::test('admin.users')
->assertDontSee('Login als User')
->call('loginAsUser', $customer->id)
->assertForbidden();
// Ohne users:manage ist die Benutzerverwaltung (und damit die
// Impersonation) bereits auf Seiten-Ebene gesperrt.
$this->get('https://pressekonto.test/admin/users')->assertForbidden();
$this->assertAuthenticatedAs($editor);
$this->assertFalse(session()->has(UserImpersonation::SessionKey));
@ -95,6 +94,52 @@ test('admin can impersonate accounts with admin access', function () {
$this->get(route('dashboard'))->assertRedirect(route('me.dashboard'));
});
test('non super admin cannot impersonate a super admin', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$admin = User::factory()->create();
$admin->assignRole('admin'); // users:manage, aber kein Super-Admin
$superAdmin = User::factory()->superAdmin()->create([
'email' => 'super@example.com',
]);
$superAdmin->assignRole('admin');
$this->actingAs($admin);
LivewireVolt::test('admin.users')
->set('search', 'super@example.com')
->call('loginAsUser', $superAdmin->id)
->assertForbidden();
$this->assertAuthenticatedAs($admin);
$this->assertFalse(session()->has(UserImpersonation::SessionKey));
});
test('super admin can impersonate another super admin', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);
$superAdmin = User::factory()->superAdmin()->create();
$superAdmin->assignRole('admin');
$targetSuperAdmin = User::factory()->superAdmin()->create([
'email' => 'target-super@example.com',
]);
$targetSuperAdmin->assignRole('admin');
$this->actingAs($superAdmin);
LivewireVolt::test('admin.users')
->set('search', 'target-super@example.com')
->call('loginAsUser', $targetSuperAdmin->id)
->assertRedirect(route('me.dashboard'));
$this->assertAuthenticatedAs($targetSuperAdmin);
expect(session(UserImpersonation::SessionKey))->toBe($superAdmin->id);
});
test('impersonated user can return to the admin account', function () {
/** @var TestCase $this */
$this->seed(RolesAndPermissionsSeeder::class);

View file

@ -1,5 +1,6 @@
<?php
use App\Actions\Admin\UserImpersonation;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\Invoice;
@ -57,6 +58,35 @@ test('customer cannot create api token without active payment access', function
expect($customer->tokens()->count())->toBe(0);
});
test('api tokens cannot be created or revoked during impersonation', function () {
/** @var TestCase $this */
$admin = User::factory()->create();
$customer = User::factory()->create(['is_active' => true]);
UserPaymentOption::factory()->create([
'user_id' => $customer->id,
'status' => UserPaymentOptionStatus::Active->value,
'current_period_start' => now()->subDay(),
'current_period_end' => now()->addMonth(),
]);
$token = $customer->createToken('Existing token', ['press-releases:read'])->accessToken;
$this->actingAs($customer)
->withSession([UserImpersonation::SessionKey => $admin->id]);
LivewireVolt::test('customer.tokens')
->assertSee('Während der Benutzer-Impersonation können keine API-Tokens erstellt oder widerrufen werden.')
->set('tokenName', 'Impersonated token')
->set('selectedAbilities', ['press-releases:read'])
->call('createToken')
->assertForbidden();
LivewireVolt::test('customer.tokens')
->call('revokeToken', $token->id)
->assertForbidden();
expect($customer->tokens()->pluck('id')->all())->toBe([$token->id]);
});
test('customer sees only own legacy invoices', function () {
/** @var TestCase $this */
$customer = User::factory()->create(['is_active' => true]);