diff --git a/app/Actions/Admin/UserImpersonation.php b/app/Actions/Admin/UserImpersonation.php index 10c3158f..dba3d134 100644 --- a/app/Actions/Admin/UserImpersonation.php +++ b/app/Actions/Admin/UserImpersonation.php @@ -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); diff --git a/app/Authorization/Permission.php b/app/Authorization/Permission.php new file mode 100644 index 00000000..b4647af9 --- /dev/null +++ b/app/Authorization/Permission.php @@ -0,0 +1,141 @@ + + */ + 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 + */ + 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 + */ + public static function editor(): array + { + return [ + self::PressReleasesRead, + self::PressReleasesWrite, + self::PressReleasesPublish, + self::PressReleaseImagesWrite, + self::CompaniesRead, + self::NewsletterSubscribe, + ]; + } + + /** + * Permissions der Rolle `customer`. + * + * @return list + */ + public static function customer(): array + { + return [ + self::PressReleasesRead, + self::PressReleasesWrite, + self::PressReleaseImagesWrite, + self::CompaniesRead, + self::NewsletterSubscribe, + ]; + } + + /** + * Permissions der Rolle `api-only`. + * + * @return list + */ + public static function apiOnly(): array + { + return [ + self::PressReleasesRead, + self::PressReleasesWrite, + self::PressReleaseImagesWrite, + self::CompaniesRead, + self::NewsletterSubscribe, + ]; + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 8c14e7f0..ba35f94b 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -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', diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index a6896449..9c3e4c3f 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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()); diff --git a/app/Services/Import/UserImporter.php b/app/Services/Import/UserImporter.php index 7619dd87..7baf2449 100644 --- a/app/Services/Import/UserImporter.php +++ b/app/Services/Import/UserImporter.php @@ -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 diff --git a/database/seeders/RolesAndPermissionsSeeder.php b/database/seeders/RolesAndPermissionsSeeder.php index ccdb7c54..a77dffd6 100644 --- a/database/seeders/RolesAndPermissionsSeeder.php +++ b/database/seeders/RolesAndPermissionsSeeder.php @@ -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()); } } diff --git a/resources/views/components/layouts/app/sidebar.blade.php b/resources/views/components/layouts/app/sidebar.blade.php index 1e5c5ffc..d4c6b1ff 100644 --- a/resources/views/components/layouts/app/sidebar.blade.php +++ b/resources/views/components/layouts/app/sidebar.blade.php @@ -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 @@ -115,14 +116,18 @@ :badge="$reviewCount > 0 ? $reviewCount : null" badge-color="yellow" wire:navigate> {{ __('Pressemitteilungen') }} - - {{ __('Kategorien') }} - - - {{ __('Footer-Codes') }} - + @can('categories:manage') + + {{ __('Kategorien') }} + + @endcan + @can('footer-codes:manage') + + {{ __('Footer-Codes') }} + + @endcan {{-- CRM --}} @@ -137,16 +142,28 @@ - {{-- Recht & Compliance --}} - - - {{ __('Anfragen') }} - - + @can('compliance:manage') + {{-- Kontakt: Anliegen aus dem Kontakt-Widget (Widget + Hub-Formular) --}} + + + {{ __('Anliegen') }} + + + + {{-- Recht & Compliance --}} + + + {{ __('Anfragen') }} + + + @endcan {{-- Billing --}} + @can('billing:manage') @@ -169,6 +186,7 @@ {{ __('Newsletter Sync') }} + @endcan {{-- Administration --}} @@ -176,22 +194,32 @@ :current="request()->routeIs('admin.presets.*')" wire:navigate> {{ __('Voreinstellungen') }} - - {{ __('Benutzer') }} - - - {{ __('Rollen & Rechte') }} - + @can('users:manage') + + {{ __('Benutzer') }} + + @endcan + @can('roles:manage') + + {{ __('Rollen & Rechte') }} + + @endcan {{-- Reports --}} + :current="request()->routeIs('admin.reports.slow-requests')" wire:navigate> {{ __('Performance') }} + @can('settings:manage') + + {{ __('Betriebsüberwachung') }} + + @endcan @endif @@ -387,6 +415,9 @@ {{ $slot }} + {{-- Schwebendes Kontakt-Widget (unten rechts, eingeloggter Bereich) --}} + + @persist('toast') @endpersist diff --git a/resources/views/livewire/admin/categories/create.blade.php b/resources/views/livewire/admin/categories/create.blade.php index 6af59c7e..2a95f3ab 100644 --- a/resources/views/livewire/admin/categories/create.blade.php +++ b/resources/views/livewire/admin/categories/create.blade.php @@ -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'], diff --git a/resources/views/livewire/admin/companies/create.blade.php b/resources/views/livewire/admin/companies/create.blade.php index f0bfd150..c38709f6 100644 --- a/resources/views/livewire/admin/companies/create.blade.php +++ b/resources/views/livewire/admin/companies/create.blade.php @@ -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]); diff --git a/resources/views/livewire/admin/companies/edit.blade.php b/resources/views/livewire/admin/companies/edit.blade.php index c759d115..0ebfdaca 100644 --- a/resources/views/livewire/admin/companies/edit.blade.php +++ b/resources/views/livewire/admin/companies/edit.blade.php @@ -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.')); diff --git a/resources/views/livewire/admin/contacts/create.blade.php b/resources/views/livewire/admin/contacts/create.blade.php index bd4689f2..569bfbd3 100644 --- a/resources/views/livewire/admin/contacts/create.blade.php +++ b/resources/views/livewire/admin/contacts/create.blade.php @@ -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()))], diff --git a/resources/views/livewire/admin/contacts/edit.blade.php b/resources/views/livewire/admin/contacts/edit.blade.php index 9aed734b..4d8f3cca 100644 --- a/resources/views/livewire/admin/contacts/edit.blade.php +++ b/resources/views/livewire/admin/contacts/edit.blade.php @@ -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()))], diff --git a/resources/views/livewire/admin/contacts/index.blade.php b/resources/views/livewire/admin/contacts/index.blade.php index 6dc635dc..d1cf2431 100644 --- a/resources/views/livewire/admin/contacts/index.blade.php +++ b/resources/views/livewire/admin/contacts/index.blade.php @@ -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.'); diff --git a/resources/views/livewire/admin/footer-codes/create.blade.php b/resources/views/livewire/admin/footer-codes/create.blade.php index a2494cbe..5e802089 100644 --- a/resources/views/livewire/admin/footer-codes/create.blade.php +++ b/resources/views/livewire/admin/footer-codes/create.blade.php @@ -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 { diff --git a/resources/views/livewire/admin/footer-codes/edit.blade.php b/resources/views/livewire/admin/footer-codes/edit.blade.php index 5537ae9c..4cda369f 100644 --- a/resources/views/livewire/admin/footer-codes/edit.blade.php +++ b/resources/views/livewire/admin/footer-codes/edit.blade.php @@ -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(); diff --git a/resources/views/livewire/admin/footer-codes/index.blade.php b/resources/views/livewire/admin/footer-codes/index.blade.php index 58a4082d..2c3f2d24 100644 --- a/resources/views/livewire/admin/footer-codes/index.blade.php +++ b/resources/views/livewire/admin/footer-codes/index.blade.php @@ -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) { diff --git a/resources/views/livewire/admin/legal-requests/index.blade.php b/resources/views/livewire/admin/legal-requests/index.blade.php index a386feb5..a5a3b281 100644 --- a/resources/views/livewire/admin/legal-requests/index.blade.php +++ b/resources/views/livewire/admin/legal-requests/index.blade.php @@ -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, diff --git a/resources/views/livewire/admin/legal-requests/show.blade.php b/resources/views/livewire/admin/legal-requests/show.blade.php index a66b8c3f..2bdca3cf 100644 --- a/resources/views/livewire/admin/legal-requests/show.blade.php +++ b/resources/views/livewire/admin/legal-requests/show.blade.php @@ -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, diff --git a/resources/views/livewire/admin/newsletter/sync.blade.php b/resources/views/livewire/admin/newsletter/sync.blade.php index 804cee61..8d23d185 100644 --- a/resources/views/livewire/admin/newsletter/sync.blade.php +++ b/resources/views/livewire/admin/newsletter/sync.blade.php @@ -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) { diff --git a/resources/views/livewire/admin/payments/index.blade.php b/resources/views/livewire/admin/payments/index.blade.php index 3354d5d1..867bee7e 100644 --- a/resources/views/livewire/admin/payments/index.blade.php +++ b/resources/views/livewire/admin/payments/index.blade.php @@ -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) { diff --git a/resources/views/livewire/admin/payments/plans.blade.php b/resources/views/livewire/admin/payments/plans.blade.php index 7782676a..e17eebee 100644 --- a/resources/views/livewire/admin/payments/plans.blade.php +++ b/resources/views/livewire/admin/payments/plans.blade.php @@ -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)); diff --git a/resources/views/livewire/admin/roles/create.blade.php b/resources/views/livewire/admin/roles/create.blade.php index e77ef336..9b79ceae 100644 --- a/resources/views/livewire/admin/roles/create.blade.php +++ b/resources/views/livewire/admin/roles/create.blade.php @@ -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', diff --git a/resources/views/livewire/admin/roles/edit.blade.php b/resources/views/livewire/admin/roles/edit.blade.php index 06164ebb..0a4de2d9 100644 --- a/resources/views/livewire/admin/roles/edit.blade.php +++ b/resources/views/livewire/admin/roles/edit.blade.php @@ -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', diff --git a/resources/views/livewire/admin/roles/index.blade.php b/resources/views/livewire/admin/roles/index.blade.php index d009dc5f..f3231ced 100644 --- a/resources/views/livewire/admin/roles/index.blade.php +++ b/resources/views/livewire/admin/roles/index.blade.php @@ -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 [ diff --git a/resources/views/livewire/admin/users.blade.php b/resources/views/livewire/admin/users.blade.php index 68187d52..9431fdaf 100644 --- a/resources/views/livewire/admin/users.blade.php +++ b/resources/views/livewire/admin/users.blade.php @@ -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']; diff --git a/resources/views/livewire/admin/users/create.blade.php b/resources/views/livewire/admin/users/create.blade.php index 0bedcdc6..0ad1c5f4 100644 --- a/resources/views/livewire/admin/users/create.blade.php +++ b/resources/views/livewire/admin/users/create.blade.php @@ -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']); diff --git a/resources/views/livewire/admin/users/edit.blade.php b/resources/views/livewire/admin/users/edit.blade.php index 7cc8847a..3602f536 100644 --- a/resources/views/livewire/admin/users/edit.blade.php +++ b/resources/views/livewire/admin/users/edit.blade.php @@ -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 = [ diff --git a/resources/views/livewire/admin/users/show.blade.php b/resources/views/livewire/admin/users/show.blade.php index 80b2df3a..d3c4c868 100644 --- a/resources/views/livewire/admin/users/show.blade.php +++ b/resources/views/livewire/admin/users/show.blade.php @@ -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()) { diff --git a/resources/views/livewire/customer/tokens.blade.php b/resources/views/livewire/customer/tokens.blade.php index 1fc52ef0..704c3359 100644 --- a/resources/views/livewire/customer/tokens.blade.php +++ b/resources/views/livewire/customer/tokens.blade.php @@ -1,5 +1,6 @@ '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), + ); + } }; ?>
@@ -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') }} diff --git a/routes/admin.php b/routes/admin.php index b9a851cc..0aa28e63 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -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'); + }); }); diff --git a/tests/Feature/Admin/AdminPrivilegeEscalationTest.php b/tests/Feature/Admin/AdminPrivilegeEscalationTest.php new file mode 100644 index 00000000..1887bea2 --- /dev/null +++ b/tests/Feature/Admin/AdminPrivilegeEscalationTest.php @@ -0,0 +1,134 @@ +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(); +}); diff --git a/tests/Feature/Admin/EditorPermissionBoundaryTest.php b/tests/Feature/Admin/EditorPermissionBoundaryTest.php new file mode 100644 index 00000000..0385df3c --- /dev/null +++ b/tests/Feature/Admin/EditorPermissionBoundaryTest.php @@ -0,0 +1,124 @@ +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(); + } +}); diff --git a/tests/Feature/Admin/UserImpersonationTest.php b/tests/Feature/Admin/UserImpersonationTest.php index 92f1bda2..9093e0d5 100644 --- a/tests/Feature/Admin/UserImpersonationTest.php +++ b/tests/Feature/Admin/UserImpersonationTest.php @@ -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); diff --git a/tests/Feature/CustomerPortalTest.php b/tests/Feature/CustomerPortalTest.php index 02be155a..f4278478 100644 --- a/tests/Feature/CustomerPortalTest.php +++ b/tests/Feature/CustomerPortalTest.php @@ -1,5 +1,6 @@ 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]);