From 7aa0a4c847189fc7ecef9fc9c0f3fcf972d5126c Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Fri, 16 Jan 2026 13:33:36 +0100 Subject: [PATCH] Enforce tenant member permissions --- .../Api/Tenant/EventController.php | 7 + .../EventGuestNotificationController.php | 3 + .../Api/Tenant/EventJoinTokenController.php | 15 +- .../Tenant/EventJoinTokenLayoutController.php | 3 + .../Api/Tenant/EventMemberController.php | 4 + .../Api/Tenant/LiveShowLinkController.php | 3 + .../Api/Tenant/LiveShowPhotoController.php | 6 + .../Api/Tenant/PhotoController.php | 16 +- .../Controllers/Api/Tenant/TaskController.php | 15 ++ app/Http/Resources/Tenant/EventResource.php | 8 + app/Support/TenantMemberPermissions.php | 174 ++++++++++++++++++ resources/js/admin/api.ts | 6 + resources/js/admin/mobile/DashboardPage.tsx | 142 ++++++++++---- .../js/admin/mobile/EventControlRoomPage.tsx | 5 +- resources/js/admin/mobile/EventsPage.tsx | 37 ++-- resources/js/admin/mobile/ProfilePage.tsx | 95 +++++----- .../mobile/__tests__/DashboardPage.test.tsx | 19 +- .../mobile/__tests__/EventsPage.test.tsx | 18 ++ .../admin/mobile/components/MobileShell.tsx | 19 +- .../components/__tests__/MobileShell.test.tsx | 4 + resources/js/admin/router.tsx | 10 +- tests/Unit/TenantMemberPermissionsTest.php | 95 ++++++++++ 22 files changed, 592 insertions(+), 112 deletions(-) create mode 100644 app/Support/TenantMemberPermissions.php create mode 100644 tests/Unit/TenantMemberPermissionsTest.php diff --git a/app/Http/Controllers/Api/Tenant/EventController.php b/app/Http/Controllers/Api/Tenant/EventController.php index 05b64d1..2454a65 100644 --- a/app/Http/Controllers/Api/Tenant/EventController.php +++ b/app/Http/Controllers/Api/Tenant/EventController.php @@ -19,6 +19,7 @@ use App\Models\Tenant; use App\Models\User; use App\Services\EventJoinTokenService; use App\Support\ApiError; +use App\Support\TenantMemberPermissions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -83,6 +84,8 @@ class EventController extends Controller public function store(EventStoreRequest $request): JsonResponse { + TenantMemberPermissions::ensureTenantPermission($request, 'events:manage'); + $tenant = $request->attributes->get('tenant'); if (! $tenant instanceof Tenant) { $tenantId = $request->attributes->get('tenant_id'); @@ -383,6 +386,8 @@ class EventController extends Controller ); } + TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage'); + $validated = $request->validated(); if (isset($validated['event_date'])) { @@ -586,6 +591,8 @@ class EventController extends Controller ); } + TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage'); + $event->delete(); return response()->json([ diff --git a/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php b/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php index 5cc33ff..1ff7689 100644 --- a/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php +++ b/app/Http/Controllers/Api/Tenant/EventGuestNotificationController.php @@ -11,6 +11,7 @@ use App\Models\Event; use App\Models\GuestNotification; use App\Models\GuestPolicySetting; use App\Services\GuestNotificationService; +use App\Support\TenantMemberPermissions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; @@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller public function index(Request $request, Event $event): JsonResponse { $this->assertEventTenant($request, $event); + TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage'); $limit = max(1, min(100, (int) $request->integer('limit', 25))); @@ -38,6 +40,7 @@ class EventGuestNotificationController extends Controller public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse { $this->assertEventTenant($request, $event); + TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage'); $data = $request->validated(); diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php index 8ad694f..f532eba 100644 --- a/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php +++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenController.php @@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource; use App\Models\Event; use App\Models\EventJoinToken; use App\Services\EventJoinTokenService; +use App\Support\TenantMemberPermissions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller public function index(Request $request, Event $event): AnonymousResourceCollection { - $this->authorizeEvent($request, $event); + $this->authorizeEvent($request, $event, 'join-tokens:manage'); $tokens = $event->joinTokens() ->orderByDesc('created_at') @@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller public function store(Request $request, Event $event): JsonResponse { - $this->authorizeEvent($request, $event); + $this->authorizeEvent($request, $event, 'join-tokens:manage'); $validated = $this->validatePayload($request); @@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource { - $this->authorizeEvent($request, $event); + $this->authorizeEvent($request, $event, 'join-tokens:manage'); if ($joinToken->event_id !== $event->id) { abort(404); @@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource { - $this->authorizeEvent($request, $event); + $this->authorizeEvent($request, $event, 'join-tokens:manage'); if ($joinToken->event_id !== $event->id) { abort(404); @@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller return new EventJoinTokenResource($token); } - private function authorizeEvent(Request $request, Event $event): void + private function authorizeEvent(Request $request, Event $event, ?string $permission = null): void { $tenantId = $request->attributes->get('tenant_id'); if ($event->tenant_id !== $tenantId) { abort(404, 'Event not found'); } + + if ($permission) { + TenantMemberPermissions::ensureEventPermission($request, $event, $permission); + } } private function validatePayload(Request $request, bool $partial = false): array diff --git a/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php index 47dc55b..a412a79 100644 --- a/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php +++ b/app/Http/Controllers/Api/Tenant/EventJoinTokenLayoutController.php @@ -6,6 +6,7 @@ use App\Http\Controllers\Controller; use App\Models\Event; use App\Models\EventJoinToken; use App\Support\JoinTokenLayoutRegistry; +use App\Support\TenantMemberPermissions; use Dompdf\Dompdf; use Dompdf\Options; use Illuminate\Http\Request; @@ -28,6 +29,7 @@ class EventJoinTokenLayoutController extends Controller public function index(Request $request, Event $event, EventJoinToken $joinToken) { $this->ensureBelongsToEvent($event, $joinToken); + TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage'); $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) { return route('api.v1.tenant.events.join-tokens.layouts.download', [ @@ -46,6 +48,7 @@ class EventJoinTokenLayoutController extends Controller public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format) { $this->ensureBelongsToEvent($event, $joinToken); + TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage'); $layoutConfig = JoinTokenLayoutRegistry::find($layout); diff --git a/app/Http/Controllers/Api/Tenant/EventMemberController.php b/app/Http/Controllers/Api/Tenant/EventMemberController.php index 733114d..c78d4ff 100644 --- a/app/Http/Controllers/Api/Tenant/EventMemberController.php +++ b/app/Http/Controllers/Api/Tenant/EventMemberController.php @@ -9,6 +9,7 @@ use App\Models\Event; use App\Models\EventMember; use App\Models\Tenant; use App\Models\User; +use App\Support\TenantMemberPermissions; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -22,6 +23,7 @@ class EventMemberController extends Controller public function index(Request $request, Event $event): JsonResponse { $this->assertEventTenant($request, $event); + TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage'); /** @var LengthAwarePaginator $members */ $members = $event->members() @@ -34,6 +36,7 @@ class EventMemberController extends Controller public function store(EventMemberInviteRequest $request, Event $event): JsonResponse { $this->assertEventTenant($request, $event); + TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage'); $data = $request->validated(); $tenant = $this->resolveTenantFromRequest($request); @@ -92,6 +95,7 @@ class EventMemberController extends Controller public function destroy(Request $request, Event $event, EventMember $member): JsonResponse { $this->assertEventTenant($request, $event); + TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage'); if ((int) $member->event_id !== (int) $event->id) { throw ValidationException::withMessages([ diff --git a/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php b/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php index f8be83e..2a76e8c 100644 --- a/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php +++ b/app/Http/Controllers/Api/Tenant/LiveShowLinkController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Models\Event; +use App\Support\TenantMemberPermissions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use SimpleSoftwareIO\QrCode\Facades\QrCode; @@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller public function show(Request $request, Event $event): JsonResponse { $this->authorizeEvent($request, $event); + TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage'); $token = $event->ensureLiveShowToken(); @@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller public function rotate(Request $request, Event $event): JsonResponse { $this->authorizeEvent($request, $event); + TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage'); $token = $event->rotateLiveShowToken(); diff --git a/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php b/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php index f2e526c..a1494ac 100644 --- a/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php +++ b/app/Http/Controllers/Api/Tenant/LiveShowPhotoController.php @@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource; use App\Models\Event; use App\Models\Photo; use App\Support\ApiError; +use App\Support\TenantMemberPermissions; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); $liveStatus = $request->string('live_status', 'pending')->toString(); $perPage = (int) $request->input('per_page', 20); @@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); if ($photo->event_id !== $event->id) { return ApiError::response( @@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); if ($photo->event_id !== $event->id) { return ApiError::response( @@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); if ($photo->event_id !== $event->id) { return ApiError::response( @@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); if ($photo->event_id !== $event->id) { return ApiError::response( diff --git a/app/Http/Controllers/Api/Tenant/PhotoController.php b/app/Http/Controllers/Api/Tenant/PhotoController.php index 5429342..61e959e 100644 --- a/app/Http/Controllers/Api/Tenant/PhotoController.php +++ b/app/Http/Controllers/Api/Tenant/PhotoController.php @@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker; use App\Services\Storage\EventStorageManager; use App\Support\ApiError; use App\Support\ImageHelper; +use App\Support\TenantMemberPermissions; use App\Support\UploadStream; use App\Support\WatermarkConfigResolver; use Illuminate\Http\JsonResponse; @@ -524,15 +525,8 @@ class PhotoController extends Controller 'alt_text' => ['sometimes', 'string', 'max:255'], ]); - // Only tenant admins can moderate - if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) { - return ApiError::response( - 'insufficient_scope', - 'Insufficient Scopes', - 'You are not allowed to moderate photos for this event.', - Response::HTTP_FORBIDDEN, - ['required_scope' => 'tenant-admin'] - ); + if (isset($validated['status'])) { + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); } $photo->update($validated); @@ -634,6 +628,7 @@ class PhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); if ($photo->event_id !== $event->id) { return ApiError::response( @@ -657,6 +652,7 @@ class PhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); if ($photo->event_id !== $event->id) { return ApiError::response( @@ -680,6 +676,7 @@ class PhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); $request->validate([ 'photo_ids' => 'required|array', @@ -725,6 +722,7 @@ class PhotoController extends Controller $event = Event::where('slug', $eventSlug) ->where('tenant_id', $tenantId) ->firstOrFail(); + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); $request->validate([ 'photo_ids' => 'required|array', diff --git a/app/Http/Controllers/Api/Tenant/TaskController.php b/app/Http/Controllers/Api/Tenant/TaskController.php index 4e33b1b..ba8cd94 100644 --- a/app/Http/Controllers/Api/Tenant/TaskController.php +++ b/app/Http/Controllers/Api/Tenant/TaskController.php @@ -11,6 +11,7 @@ use App\Models\Task; use App\Models\TaskCollection; use App\Models\Tenant; use App\Support\ApiError; +use App\Support\TenantMemberPermissions; use App\Support\TenantRequestResolver; use Illuminate\Http\JsonResponse; use Illuminate\Http\Request; @@ -66,6 +67,8 @@ class TaskController extends Controller */ public function store(TaskStoreRequest $request): JsonResponse { + TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage'); + $tenant = $this->currentTenant($request); $collectionId = $request->input('collection_id'); $collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null; @@ -107,6 +110,8 @@ class TaskController extends Controller */ public function update(TaskUpdateRequest $request, Task $task): JsonResponse { + TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage'); + $tenant = $this->currentTenant($request); if ($task->tenant_id !== $tenant->id) { @@ -138,6 +143,8 @@ class TaskController extends Controller */ public function destroy(Request $request, Task $task): JsonResponse { + TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage'); + if ($task->tenant_id !== $this->currentTenant($request)->id) { abort(404, 'Task nicht gefunden.'); } @@ -154,6 +161,8 @@ class TaskController extends Controller */ public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse { + TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); + $tenantId = $this->currentTenant($request)->id; if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) { @@ -176,6 +185,8 @@ class TaskController extends Controller */ public function bulkAssignToEvent(Request $request, Event $event): JsonResponse { + TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); + $tenantId = $this->currentTenant($request)->id; if ($event->tenant_id !== $tenantId) { @@ -230,6 +241,8 @@ class TaskController extends Controller public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse { + TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); + $tenantId = $this->currentTenant($request)->id; if ($event->tenant_id !== $tenantId) { @@ -256,6 +269,8 @@ class TaskController extends Controller public function reorderForEvent(Request $request, Event $event): JsonResponse { + TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage'); + $tenantId = $this->currentTenant($request)->id; if ($event->tenant_id !== $tenantId) { diff --git a/app/Http/Resources/Tenant/EventResource.php b/app/Http/Resources/Tenant/EventResource.php index b509725..3174dc1 100644 --- a/app/Http/Resources/Tenant/EventResource.php +++ b/app/Http/Resources/Tenant/EventResource.php @@ -3,6 +3,7 @@ namespace App\Http\Resources\Tenant; use App\Services\Packages\PackageLimitEvaluator; +use App\Support\TenantMemberPermissions; use Illuminate\Http\Request; use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\MissingValue; @@ -18,6 +19,12 @@ class EventResource extends JsonResource $showSensitive = $this->tenant_id === $tenantId; $settings = is_array($this->settings) ? $this->settings : []; $eventPackage = null; + $memberPermissions = null; + + $user = $request->user(); + if ($user && $user->role === 'member') { + $memberPermissions = TenantMemberPermissions::resolveEventPermissions($request, $this->resource); + } if ($this->relationLoaded('eventPackages')) { $related = $this->getRelation('eventPackages'); @@ -86,6 +93,7 @@ class EventResource extends JsonResource ? $limitEvaluator->summarizeEventPackage($eventPackage) : null, 'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [], + 'member_permissions' => $memberPermissions, ]; } diff --git a/app/Support/TenantMemberPermissions.php b/app/Support/TenantMemberPermissions.php new file mode 100644 index 0000000..5515c90 --- /dev/null +++ b/app/Support/TenantMemberPermissions.php @@ -0,0 +1,174 @@ + + */ + public static function resolveEventPermissions(Request $request, Event $event): array + { + $user = $request->user(); + + if (! $user instanceof User) { + return []; + } + + if (self::isTenantAdmin($user)) { + return ['*']; + } + + $member = self::resolveEventMember($user, $event); + + if (! $member) { + return []; + } + + return self::normalizePermissions($member->permissions); + } + + public static function ensureEventPermission(Request $request, Event $event, string $permission): void + { + if (self::allowsPermission(self::resolveEventPermissions($request, $event), $permission)) { + return; + } + + throw new HttpResponseException(ApiError::response( + 'insufficient_permission', + 'Insufficient permission', + 'You are not allowed to perform this action.', + Response::HTTP_FORBIDDEN, + ['required_permission' => $permission] + )); + } + + public static function allowsEventPermission(Request $request, Event $event, string $permission): bool + { + return self::allowsPermission(self::resolveEventPermissions($request, $event), $permission); + } + + public static function ensureTenantPermission(Request $request, string $permission): void + { + $user = $request->user(); + + if (! $user instanceof User) { + throw new HttpResponseException(ApiError::response( + 'unauthenticated', + 'Unauthenticated', + 'You must be authenticated to perform this action.', + Response::HTTP_UNAUTHORIZED + )); + } + + if (self::isTenantAdmin($user)) { + return; + } + + $permissions = self::resolveTenantMemberPermissions($user); + + if (self::allowsPermission($permissions, $permission)) { + return; + } + + throw new HttpResponseException(ApiError::response( + 'insufficient_permission', + 'Insufficient permission', + 'You are not allowed to perform this action.', + Response::HTTP_FORBIDDEN, + ['required_permission' => $permission] + )); + } + + /** + * @return array + */ + private static function resolveTenantMemberPermissions(User $user): array + { + if (! $user->tenant_id) { + return []; + } + + $memberships = EventMember::query() + ->where('tenant_id', $user->tenant_id) + ->whereIn('status', ['active', 'invited']) + ->where(function ($query) use ($user) { + $query->where('user_id', $user->id) + ->orWhere('email', $user->email); + }) + ->get(['permissions']); + + $permissions = []; + + foreach ($memberships as $member) { + $permissions = array_merge($permissions, self::normalizePermissions($member->permissions)); + } + + return array_values(array_unique($permissions)); + } + + private static function isTenantAdmin(User $user): bool + { + return in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'superadmin'], true); + } + + private static function resolveEventMember(User $user, Event $event): ?EventMember + { + return EventMember::query() + ->where('tenant_id', $event->tenant_id) + ->where('event_id', $event->id) + ->whereIn('status', ['active', 'invited']) + ->where(function ($query) use ($user) { + $query->where('user_id', $user->id) + ->orWhere('email', $user->email); + }) + ->first(); + } + + /** + * @param array|string|null $permissions + * @return array + */ + private static function normalizePermissions(mixed $permissions): array + { + if (is_array($permissions)) { + return array_values(array_filter(array_map('strval', $permissions))); + } + + if (is_string($permissions) && $permissions !== '') { + return array_values(array_filter(array_map('trim', explode(',', $permissions)))); + } + + return []; + } + + /** + * @param array $permissions + */ + private static function allowsPermission(array $permissions, string $permission): bool + { + foreach ($permissions as $entry) { + if ($entry === '*' || $entry === $permission) { + return true; + } + + if (Str::endsWith($entry, ':*')) { + $prefix = Str::beforeLast($entry, '*'); + + if (Str::startsWith($permission, $prefix)) { + return true; + } + } + } + + return false; + } +} diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index 75a10ab..c578c91 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -116,6 +116,7 @@ export type TenantEvent = { } | null; limits?: EventLimitSummary | null; addons?: EventAddonSummary[]; + member_permissions?: string[] | null; [key: string]: unknown; }; @@ -933,6 +934,11 @@ function normalizeEvent(event: JsonValue): TenantEvent { settings, package: event.package ?? null, limits: (event.limits ?? null) as EventLimitSummary | null, + member_permissions: Array.isArray(event.member_permissions) + ? (event.member_permissions as string[]) + : event.member_permissions + ? String(event.member_permissions).split(',').map((entry) => entry.trim()) + : null, }; return normalized; diff --git a/resources/js/admin/mobile/DashboardPage.tsx b/resources/js/admin/mobile/DashboardPage.tsx index 9c1e283..30eadbf 100644 --- a/resources/js/admin/mobile/DashboardPage.tsx +++ b/resources/js/admin/mobile/DashboardPage.tsx @@ -33,13 +33,37 @@ type DeviceSetupProps = { onOpenSettings: () => void; }; +function allowPermission(permissions: string[], permission: string): boolean { + if (permissions.includes('*') || permissions.includes(permission)) { + return true; + } + + if (permission.includes(':')) { + const [prefix] = permission.split(':'); + return permissions.includes(`${prefix}:*`); + } + + return false; +} + export default function MobileDashboardPage() { const navigate = useNavigate(); const location = useLocation(); const { slug: slugParam } = useParams<{ slug?: string }>(); const { t, i18n } = useTranslation('management'); const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext(); - const { status } = useAuth(); + const { status, user } = useAuth(); + const isMember = user?.role === 'member'; + const memberPermissions = React.useMemo(() => { + if (!isMember) { + return ['*']; + } + return Array.isArray(activeEvent?.member_permissions) ? activeEvent?.member_permissions ?? [] : []; + }, [activeEvent?.member_permissions, isMember]); + const canManageEvents = React.useMemo( + () => allowPermission(memberPermissions, 'events:manage'), + [memberPermissions] + ); const [fallbackEvents, setFallbackEvents] = React.useState([]); const [fallbackLoading, setFallbackLoading] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false); @@ -150,6 +174,7 @@ export default function MobileDashboardPage() { const hasSummaryPackage = Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId); const shouldRedirectToBilling = + !isMember && !packagesLoading && !packagesError && !effectiveHasEvents && @@ -210,7 +235,7 @@ export default function MobileDashboardPage() { closeTour(); navigate(adminPath('/mobile/events/new')); }, - showAction: true, + showAction: canManageEvents, }, qr: { key: 'qr', @@ -257,7 +282,7 @@ export default function MobileDashboardPage() { }; return tourStepKeys.map((key) => stepMap[key]); - }, [closeTour, navigate, t, tourStepKeys, tourTargetSlug]); + }, [canManageEvents, closeTour, navigate, t, tourStepKeys, tourTargetSlug]); const activeTourStep = tourSteps[tourStep] ?? tourSteps[0]; const totalTourSteps = tourSteps.length; @@ -356,6 +381,7 @@ export default function MobileDashboardPage() { handleSummaryClose(); navigate(adminPath('/mobile/events/new')); }} + showContinue={canManageEvents} packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')} packageType={activePackage.package_type ?? null} remainingEvents={remainingEvents} @@ -462,11 +488,18 @@ export default function MobileDashboardPage() { locale={locale} canSwitch={effectiveMultiple} onSwitch={() => setEventSwitcherOpen(true)} - onEdit={() => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`))} + canEdit={canManageEvents} + onEdit={ + canManageEvents + ? () => activeEvent?.slug && navigate(adminPath(`/mobile/events/${activeEvent.slug}/edit`)) + : undefined + } /> navigate(path)} /> void; onContinue: () => void; + showContinue: boolean; packageName: string; packageType: string | null; remainingEvents: number | null | undefined; @@ -600,7 +635,9 @@ function PackageSummarySheet({ - + {showContinue ? ( + + ) : null} @@ -919,12 +956,19 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO {t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')} - navigate(adminPath('/mobile/events/new'))} /> - navigate(ADMIN_WELCOME_BASE_PATH)} - /> + {canManageEvents ? ( + <> + navigate(adminPath('/mobile/events/new'))} + /> + navigate(ADMIN_WELCOME_BASE_PATH)} + /> + + ) : null} @@ -1156,13 +1200,15 @@ function EventHeaderCard({ locale, canSwitch, onSwitch, + canEdit, onEdit, }: { event: TenantEvent | null; locale: string; canSwitch: boolean; onSwitch: () => void; - onEdit: () => void; + canEdit: boolean; + onEdit?: () => void; }) { const { t } = useTranslation('management'); const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme(); @@ -1222,24 +1268,26 @@ function EventHeaderCard({ - - - + {canEdit && onEdit ? ( + + + + ) : null} ); } @@ -1247,10 +1295,14 @@ function EventHeaderCard({ function EventManagementGrid({ event, tasksEnabled, + isMember, + permissions, onNavigate, }: { event: TenantEvent | null; tasksEnabled: boolean; + isMember: boolean; + permissions: string[]; onNavigate: (path: string) => void; }) { const { t } = useTranslation('management'); @@ -1263,81 +1315,103 @@ function EventManagementGrid({ } const tiles = [ { + key: 'settings', icon: Pencil, label: t('mobileDashboard.shortcutSettings', 'Event settings'), color: ADMIN_ACTION_COLORS.settings, + requiredPermission: 'events:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined, disabled: !slug, }, { + key: 'tasks', icon: Sparkles, label: tasksEnabled ? t('events.quick.tasks', 'Tasks & Checklists') : `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`, color: ADMIN_ACTION_COLORS.tasks, + requiredPermission: 'tasks:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined, disabled: !tasksEnabled || !slug, }, { + key: 'qr', icon: QrCode, label: t('events.quick.qr', 'QR Code Layouts'), color: ADMIN_ACTION_COLORS.qr, + requiredPermission: 'join-tokens:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined, disabled: !slug, }, { + key: 'images', icon: ImageIcon, label: t('events.quick.images', 'Image Management'), color: ADMIN_ACTION_COLORS.images, + requiredPermission: 'photos:moderate', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined, disabled: !slug, }, { + key: 'controlRoom', icon: Tv, label: t('events.quick.controlRoom', 'Moderation & Live Show'), color: ADMIN_ACTION_COLORS.liveShow, + requiredPermission: 'photos:moderate', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined, disabled: !slug, }, { + key: 'liveShowSettings', icon: Settings, label: t('events.quick.liveShowSettings', 'Live Show settings'), color: ADMIN_ACTION_COLORS.liveShowSettings, + requiredPermission: 'live-show:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined, disabled: !slug, }, { + key: 'members', icon: Users, label: t('events.quick.guests', 'Guest Management'), color: ADMIN_ACTION_COLORS.guests, + requiredPermission: 'members:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined, disabled: !slug, }, { + key: 'guestMessages', icon: Megaphone, label: t('events.quick.guestMessages', 'Guest messages'), color: ADMIN_ACTION_COLORS.guestMessages, + requiredPermission: 'guest-notifications:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined, disabled: !slug, }, { + key: 'branding', icon: Layout, label: t('events.quick.branding', 'Branding & Theme'), color: ADMIN_ACTION_COLORS.branding, + requiredPermission: 'events:manage', onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined, disabled: !brandingAllowed || !slug, }, { + key: 'photobooth', icon: Camera, label: t('events.quick.photobooth', 'Photobooth'), color: ADMIN_ACTION_COLORS.photobooth, + requiredPermission: 'events:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined, disabled: !slug, }, { + key: 'analytics', icon: TrendingUp, label: t('mobileDashboard.shortcutAnalytics', 'Analytics'), color: ADMIN_ACTION_COLORS.analytics, + requiredPermission: 'events:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined, disabled: !slug, }, @@ -1345,16 +1419,22 @@ function EventManagementGrid({ if (event && isPastEvent(event.event_date)) { tiles.push({ + key: 'recap', icon: Sparkles, label: t('events.quick.recap', 'Recap & Archive'), color: ADMIN_ACTION_COLORS.recap, + requiredPermission: 'events:manage', onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined, disabled: !slug, }); } + const visibleTiles = isMember + ? tiles.filter((tile) => !tile.requiredPermission || allowPermission(permissions, tile.requiredPermission)) + : tiles; + const rows: typeof tiles[] = []; - tiles.forEach((tile, index) => { + visibleTiles.forEach((tile, index) => { const rowIndex = Math.floor(index / 2); if (!rows[rowIndex]) { rows[rowIndex] = []; diff --git a/resources/js/admin/mobile/EventControlRoomPage.tsx b/resources/js/admin/mobile/EventControlRoomPage.tsx index 55a8a8b..087c588 100644 --- a/resources/js/admin/mobile/EventControlRoomPage.tsx +++ b/resources/js/admin/mobile/EventControlRoomPage.tsx @@ -36,6 +36,7 @@ import toast from 'react-hot-toast'; import { useBackNavigation } from './hooks/useBackNavigation'; import { useAdminTheme } from './theme'; import { useOnlineStatus } from './hooks/useOnlineStatus'; +import { useAuth } from '../auth/context'; import { enqueuePhotoAction, loadPhotoQueue, @@ -91,6 +92,8 @@ export default function MobileEventControlRoomPage() { const location = useLocation(); const { t } = useTranslation('management'); const { activeEvent, selectEvent } = useEventContext(); + const { user } = useAuth(); + const isMember = user?.role === 'member'; const slug = slugParam ?? activeEvent?.slug ?? null; const online = useOnlineStatus(); const { textStrong, text, muted, border, accentSoft, accent, danger } = useAdminTheme(); @@ -574,7 +577,7 @@ export default function MobileEventControlRoomPage() { > - {slug ? ( + {slug && !isMember ? ( navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))} ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')} diff --git a/resources/js/admin/mobile/EventsPage.tsx b/resources/js/admin/mobile/EventsPage.tsx index 08c83c8..3f2c0ff 100644 --- a/resources/js/admin/mobile/EventsPage.tsx +++ b/resources/js/admin/mobile/EventsPage.tsx @@ -20,10 +20,13 @@ import { useBackNavigation } from './hooks/useBackNavigation'; import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters'; import { buildEventListStats } from './lib/eventListStats'; import { useAdminTheme } from './theme'; +import { useAuth } from '../auth/context'; export default function MobileEventsPage() { const { t } = useTranslation('management'); const navigate = useNavigate(); + const { user } = useAuth(); + const isMember = user?.role === 'member'; const [events, setEvents] = React.useState([]); const [loading, setLoading] = React.useState(true); const [error, setError] = React.useState(null); @@ -139,7 +142,9 @@ export default function MobileEventsPage() { {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')} - navigate(adminPath('/events/new'))} /> + {!isMember ? ( + navigate(adminPath('/events/new'))} /> + ) : null} ) : ( @@ -149,15 +154,17 @@ export default function MobileEventsPage() { statusFilter={statusFilter} onStatusChange={setStatusFilter} onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))} - onEdit={(slug) => navigate(adminPath(`/mobile/events/${slug}/edit`))} + onEdit={!isMember ? (slug) => navigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined} /> )} - navigate(adminPath('/mobile/events/new'))} - /> + {!isMember ? ( + navigate(adminPath('/mobile/events/new'))} + /> + ) : null} ); } @@ -175,7 +182,7 @@ function EventsList({ statusFilter: EventStatusKey; onStatusChange: (value: EventStatusKey) => void; onOpen: (slug: string) => void; - onEdit: (slug: string) => void; + onEdit?: (slug: string) => void; }) { const { t } = useTranslation('management'); const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme(); @@ -350,7 +357,7 @@ function EventRow({ statusLabel: string; statusTone: 'success' | 'warning' | 'muted'; onOpen: (slug: string) => void; - onEdit: (slug: string) => void; + onEdit?: (slug: string) => void; }) { const { t } = useTranslation('management'); const stats = buildEventListStats(event); @@ -386,11 +393,13 @@ function EventRow({ {statusLabel} - onEdit(event.slug)}> - - ˅ - - + {onEdit ? ( + onEdit(event.slug)}> + + ˅ + + + ) : null} diff --git a/resources/js/admin/mobile/ProfilePage.tsx b/resources/js/admin/mobile/ProfilePage.tsx index 43b8203..5f63281 100644 --- a/resources/js/admin/mobile/ProfilePage.tsx +++ b/resources/js/admin/mobile/ProfilePage.tsx @@ -22,6 +22,7 @@ import { useAdminTheme } from './theme'; export default function MobileProfilePage() { const { user, logout } = useAuth(); + const isMember = user?.role === 'member'; const navigate = useNavigate(); const { t } = useTranslation('management'); const { appearance, updateAppearance } = useAppearance(); @@ -130,51 +131,55 @@ export default function MobileProfilePage() { onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)} /> - - - {t('billing.sections.packages.title', 'Packages & Billing')} - - } - iconAfter={} - onPress={() => navigate(adminPath('/mobile/billing#packages'))} - /> - - - - {t('billing.sections.invoices.title', 'Invoices & Payments')} - - } - iconAfter={} - onPress={() => navigate(adminPath('/mobile/billing#invoices'))} - /> - - - - {t('dataExports.title', 'Data exports')} - - } - iconAfter={} - onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)} - /> - + {!isMember ? ( + <> + + + {t('billing.sections.packages.title', 'Packages & Billing')} + + } + iconAfter={} + onPress={() => navigate(adminPath('/mobile/billing#packages'))} + /> + + + + {t('billing.sections.invoices.title', 'Invoices & Payments')} + + } + iconAfter={} + onPress={() => navigate(adminPath('/mobile/billing#invoices'))} + /> + + + + {t('dataExports.title', 'Data exports')} + + } + iconAfter={} + onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)} + /> + + + ) : null} diff --git a/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx b/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx index e2ac929..8c40941 100644 --- a/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/DashboardPage.test.tsx @@ -14,6 +14,7 @@ const fixtures = vi.hoisted(() => ({ photo_count: 12, active_invites_count: 3, total_invites_count: 5, + member_permissions: ['photos:moderate', 'tasks:manage', 'join-tokens:manage'], }, activePackage: { id: 1, @@ -36,6 +37,10 @@ const fixtures = vi.hoisted(() => ({ })); const navigateMock = vi.fn(); +const authState = { + status: 'authenticated', + user: { role: 'tenant_admin' }, +}; vi.mock('react-router-dom', () => ({ useNavigate: () => navigateMock, @@ -103,7 +108,7 @@ vi.mock('../../context/EventContext', () => ({ })); vi.mock('../../auth/context', () => ({ - useAuth: () => ({ status: 'unauthenticated' }), + useAuth: () => authState, })); vi.mock('../hooks/useInstallPrompt', () => ({ @@ -232,4 +237,16 @@ describe('MobileDashboardPage', () => { expect(screen.getByText('2 of 5 events used')).toBeInTheDocument(); expect(screen.getByText('3 remaining')).toBeInTheDocument(); }); + + it('hides admin-only shortcuts for members', () => { + authState.user = { role: 'member' }; + + render(); + + expect(screen.getByText('Moderation & Live Show')).toBeInTheDocument(); + expect(screen.queryByText('Event settings')).not.toBeInTheDocument(); + expect(screen.queryByText('Live Show settings')).not.toBeInTheDocument(); + + authState.user = { role: 'tenant_admin' }; + }); }); diff --git a/resources/js/admin/mobile/__tests__/EventsPage.test.tsx b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx index f8b54a8..d2224bb 100644 --- a/resources/js/admin/mobile/__tests__/EventsPage.test.tsx +++ b/resources/js/admin/mobile/__tests__/EventsPage.test.tsx @@ -3,6 +3,9 @@ import { describe, expect, it, vi } from 'vitest'; import { render, screen } from '@testing-library/react'; const navigateMock = vi.fn(); +const authState = { + user: { role: 'tenant_admin' }, +}; vi.mock('react-router-dom', () => ({ useNavigate: () => navigateMock, @@ -38,6 +41,10 @@ vi.mock('../../auth/tokens', () => ({ isAuthError: () => false, })); +vi.mock('../../auth/context', () => ({ + useAuth: () => authState, +})); + vi.mock('../../lib/apiError', () => ({ getApiErrorMessage: () => 'error', })); @@ -133,4 +140,15 @@ describe('MobileEventsPage', () => { expect(screen.getByText('Status')).toBeInTheDocument(); expect(screen.getByText('Demo Event')).toBeInTheDocument(); }); + + it('hides create actions for members', async () => { + authState.user = { role: 'member' }; + + render(); + + expect(await screen.findByText('Demo Event')).toBeInTheDocument(); + expect(screen.queryByText('Create New Event')).not.toBeInTheDocument(); + + authState.user = { role: 'tenant_admin' }; + }); }); diff --git a/resources/js/admin/mobile/components/MobileShell.tsx b/resources/js/admin/mobile/components/MobileShell.tsx index 0afe40d..6902e3c 100644 --- a/resources/js/admin/mobile/components/MobileShell.tsx +++ b/resources/js/admin/mobile/components/MobileShell.tsx @@ -19,6 +19,7 @@ import { setTabHistory } from '../lib/tabHistory'; import { loadPhotoQueue } from '../lib/photoModerationQueue'; import { countQueuedPhotoActions } from '../lib/queueStatus'; import { useAdminTheme } from '../theme'; +import { useAuth } from '../../auth/context'; type MobileShellProps = { title?: string; @@ -31,6 +32,7 @@ type MobileShellProps = { export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) { const { events, activeEvent, selectEvent } = useEventContext(); + const { user } = useAuth(); const { go } = useMobileNav(activeEvent?.slug, activeTab); const navigate = useNavigate(); const location = useLocation(); @@ -137,7 +139,22 @@ export function MobileShell({ title, subtitle, children, activeTab, onBack, head const pageTitle = title ?? t('header.appName', 'Event Admin'); const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null; const subtitleText = subtitle ?? eventContext ?? ''; - const showQr = Boolean(effectiveActive?.slug); + const isMember = user?.role === 'member'; + const memberPermissions = Array.isArray(effectiveActive?.member_permissions) ? effectiveActive?.member_permissions ?? [] : []; + const allowPermission = (permission: string) => { + if (!isMember) { + return true; + } + if (memberPermissions.includes('*') || memberPermissions.includes(permission)) { + return true; + } + if (permission.includes(':')) { + const [prefix] = permission.split(':'); + return memberPermissions.includes(`${prefix}:*`); + } + return false; + }; + const showQr = Boolean(effectiveActive?.slug) && allowPermission('join-tokens:manage'); const headerBackButton = onBack ? ( diff --git a/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx b/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx index 358e45d..1254729 100644 --- a/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx +++ b/resources/js/admin/mobile/components/__tests__/MobileShell.test.tsx @@ -52,6 +52,10 @@ vi.mock('../../../context/EventContext', () => ({ }), })); +vi.mock('../../../auth/context', () => ({ + useAuth: () => ({ user: { role: 'tenant_admin' } }), +})); + vi.mock('../../hooks/useMobileNav', () => ({ useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }), })); diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index e089b3c..83f3d7e 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -198,9 +198,9 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/branding', element: }, { path: 'mobile/events/new', element: }, { path: 'mobile/events/:slug/edit', element: }, - { path: 'mobile/events/:slug/qr', element: }, - { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: }, - { path: 'mobile/events/:slug/control-room', element: }, + { path: 'mobile/events/:slug/qr', element: }, + { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: }, + { path: 'mobile/events/:slug/control-room', element: }, { path: 'mobile/events/:slug/photos/:photoId?', element: `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'mobile/events/:slug/live-show', element: `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'mobile/events/:slug/live-show/settings', element: }, @@ -212,8 +212,8 @@ export const router = createBrowserRouter([ { path: 'mobile/events/:slug/guest-notifications', element: }, { path: 'mobile/notifications', element: }, { path: 'mobile/notifications/:notificationId', element: }, - { path: 'mobile/profile', element: }, - { path: 'mobile/profile/account', element: }, + { path: 'mobile/profile', element: }, + { path: 'mobile/profile/account', element: }, { path: 'mobile/billing', element: }, { path: 'mobile/billing/shop', element: }, { path: 'mobile/settings', element: }, diff --git a/tests/Unit/TenantMemberPermissionsTest.php b/tests/Unit/TenantMemberPermissionsTest.php new file mode 100644 index 0000000..dd2aa03 --- /dev/null +++ b/tests/Unit/TenantMemberPermissionsTest.php @@ -0,0 +1,95 @@ +create(); + $event = Event::factory()->for($tenant)->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'member', + ]); + + EventMember::factory()->create([ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + 'user_id' => $user->id, + 'email' => $user->email, + 'status' => 'active', + 'permissions' => ['photos:moderate', 'tasks:manage'], + ]); + + $request = Request::create('/'); + $request->setUserResolver(fn () => $user); + + $permissions = TenantMemberPermissions::resolveEventPermissions($request, $event); + + $this->assertContains('photos:moderate', $permissions); + $this->assertContains('tasks:manage', $permissions); + } + + public function test_allows_wildcard_permissions(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'member', + ]); + + EventMember::factory()->create([ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + 'user_id' => $user->id, + 'email' => $user->email, + 'status' => 'active', + 'permissions' => ['photos:*'], + ]); + + $request = Request::create('/'); + $request->setUserResolver(fn () => $user); + + $this->assertTrue(TenantMemberPermissions::allowsEventPermission($request, $event, 'photos:moderate')); + } + + public function test_denies_missing_permissions(): void + { + $tenant = Tenant::factory()->create(); + $event = Event::factory()->for($tenant)->create(); + $user = User::factory()->create([ + 'tenant_id' => $tenant->id, + 'role' => 'member', + ]); + + EventMember::factory()->create([ + 'tenant_id' => $tenant->id, + 'event_id' => $event->id, + 'user_id' => $user->id, + 'email' => $user->email, + 'status' => 'active', + 'permissions' => ['tasks:manage'], + ]); + + $request = Request::create('/'); + $request->setUserResolver(fn () => $user); + + $this->expectException(HttpResponseException::class); + + TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate'); + } +}