Enforce tenant member permissions
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-01-16 13:33:36 +01:00
parent df60be826d
commit 7aa0a4c847
22 changed files with 592 additions and 112 deletions

View File

@@ -19,6 +19,7 @@ use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -83,6 +84,8 @@ class EventController extends Controller
public function store(EventStoreRequest $request): JsonResponse public function store(EventStoreRequest $request): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'events:manage');
$tenant = $request->attributes->get('tenant'); $tenant = $request->attributes->get('tenant');
if (! $tenant instanceof Tenant) { if (! $tenant instanceof Tenant) {
$tenantId = $request->attributes->get('tenant_id'); $tenantId = $request->attributes->get('tenant_id');
@@ -383,6 +386,8 @@ class EventController extends Controller
); );
} }
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$validated = $request->validated(); $validated = $request->validated();
if (isset($validated['event_date'])) { if (isset($validated['event_date'])) {
@@ -586,6 +591,8 @@ class EventController extends Controller
); );
} }
TenantMemberPermissions::ensureEventPermission($request, $event, 'events:manage');
$event->delete(); $event->delete();
return response()->json([ return response()->json([

View File

@@ -11,6 +11,7 @@ use App\Models\Event;
use App\Models\GuestNotification; use App\Models\GuestNotification;
use App\Models\GuestPolicySetting; use App\Models\GuestPolicySetting;
use App\Services\GuestNotificationService; use App\Services\GuestNotificationService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
@@ -23,6 +24,7 @@ class EventGuestNotificationController extends Controller
public function index(Request $request, Event $event): JsonResponse public function index(Request $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$limit = max(1, min(100, (int) $request->integer('limit', 25))); $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 public function store(BroadcastGuestNotificationRequest $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'guest-notifications:manage');
$data = $request->validated(); $data = $request->validated();

View File

@@ -7,6 +7,7 @@ use App\Http\Resources\Tenant\EventJoinTokenResource;
use App\Models\Event; use App\Models\Event;
use App\Models\EventJoinToken; use App\Models\EventJoinToken;
use App\Services\EventJoinTokenService; use App\Services\EventJoinTokenService;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -19,7 +20,7 @@ class EventJoinTokenController extends Controller
public function index(Request $request, Event $event): AnonymousResourceCollection public function index(Request $request, Event $event): AnonymousResourceCollection
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event, 'join-tokens:manage');
$tokens = $event->joinTokens() $tokens = $event->joinTokens()
->orderByDesc('created_at') ->orderByDesc('created_at')
@@ -30,7 +31,7 @@ class EventJoinTokenController extends Controller
public function store(Request $request, Event $event): JsonResponse public function store(Request $request, Event $event): JsonResponse
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event, 'join-tokens:manage');
$validated = $this->validatePayload($request); $validated = $this->validatePayload($request);
@@ -45,7 +46,7 @@ class EventJoinTokenController extends Controller
public function update(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource 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) { if ($joinToken->event_id !== $event->id) {
abort(404); abort(404);
@@ -89,7 +90,7 @@ class EventJoinTokenController extends Controller
public function destroy(Request $request, Event $event, EventJoinToken $joinToken): EventJoinTokenResource 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) { if ($joinToken->event_id !== $event->id) {
abort(404); abort(404);
@@ -101,13 +102,17 @@ class EventJoinTokenController extends Controller
return new EventJoinTokenResource($token); 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'); $tenantId = $request->attributes->get('tenant_id');
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
abort(404, 'Event not found'); abort(404, 'Event not found');
} }
if ($permission) {
TenantMemberPermissions::ensureEventPermission($request, $event, $permission);
}
} }
private function validatePayload(Request $request, bool $partial = false): array private function validatePayload(Request $request, bool $partial = false): array

View File

@@ -6,6 +6,7 @@ use App\Http\Controllers\Controller;
use App\Models\Event; use App\Models\Event;
use App\Models\EventJoinToken; use App\Models\EventJoinToken;
use App\Support\JoinTokenLayoutRegistry; use App\Support\JoinTokenLayoutRegistry;
use App\Support\TenantMemberPermissions;
use Dompdf\Dompdf; use Dompdf\Dompdf;
use Dompdf\Options; use Dompdf\Options;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -28,6 +29,7 @@ class EventJoinTokenLayoutController extends Controller
public function index(Request $request, Event $event, EventJoinToken $joinToken) public function index(Request $request, Event $event, EventJoinToken $joinToken)
{ {
$this->ensureBelongsToEvent($event, $joinToken); $this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) { $layouts = JoinTokenLayoutRegistry::toResponse(function (string $layoutId, string $format) use ($event, $joinToken) {
return route('api.v1.tenant.events.join-tokens.layouts.download', [ 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) public function download(Request $request, Event $event, EventJoinToken $joinToken, string $layout, string $format)
{ {
$this->ensureBelongsToEvent($event, $joinToken); $this->ensureBelongsToEvent($event, $joinToken);
TenantMemberPermissions::ensureEventPermission($request, $event, 'join-tokens:manage');
$layoutConfig = JoinTokenLayoutRegistry::find($layout); $layoutConfig = JoinTokenLayoutRegistry::find($layout);

View File

@@ -9,6 +9,7 @@ use App\Models\Event;
use App\Models\EventMember; use App\Models\EventMember;
use App\Models\Tenant; use App\Models\Tenant;
use App\Models\User; use App\Models\User;
use App\Support\TenantMemberPermissions;
use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -22,6 +23,7 @@ class EventMemberController extends Controller
public function index(Request $request, Event $event): JsonResponse public function index(Request $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
/** @var LengthAwarePaginator $members */ /** @var LengthAwarePaginator $members */
$members = $event->members() $members = $event->members()
@@ -34,6 +36,7 @@ class EventMemberController extends Controller
public function store(EventMemberInviteRequest $request, Event $event): JsonResponse public function store(EventMemberInviteRequest $request, Event $event): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
$data = $request->validated(); $data = $request->validated();
$tenant = $this->resolveTenantFromRequest($request); $tenant = $this->resolveTenantFromRequest($request);
@@ -92,6 +95,7 @@ class EventMemberController extends Controller
public function destroy(Request $request, Event $event, EventMember $member): JsonResponse public function destroy(Request $request, Event $event, EventMember $member): JsonResponse
{ {
$this->assertEventTenant($request, $event); $this->assertEventTenant($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'members:manage');
if ((int) $member->event_id !== (int) $event->id) { if ((int) $member->event_id !== (int) $event->id) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller; use App\Http\Controllers\Controller;
use App\Models\Event; use App\Models\Event;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use SimpleSoftwareIO\QrCode\Facades\QrCode; use SimpleSoftwareIO\QrCode\Facades\QrCode;
@@ -13,6 +14,7 @@ class LiveShowLinkController extends Controller
public function show(Request $request, Event $event): JsonResponse public function show(Request $request, Event $event): JsonResponse
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->ensureLiveShowToken(); $token = $event->ensureLiveShowToken();
@@ -24,6 +26,7 @@ class LiveShowLinkController extends Controller
public function rotate(Request $request, Event $event): JsonResponse public function rotate(Request $request, Event $event): JsonResponse
{ {
$this->authorizeEvent($request, $event); $this->authorizeEvent($request, $event);
TenantMemberPermissions::ensureEventPermission($request, $event, 'live-show:manage');
$token = $event->rotateLiveShowToken(); $token = $event->rotateLiveShowToken();

View File

@@ -10,6 +10,7 @@ use App\Http\Resources\Tenant\PhotoResource;
use App\Models\Event; use App\Models\Event;
use App\Models\Photo; use App\Models\Photo;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\AnonymousResourceCollection; use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
@@ -23,6 +24,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$liveStatus = $request->string('live_status', 'pending')->toString(); $liveStatus = $request->string('live_status', 'pending')->toString();
$perPage = (int) $request->input('per_page', 20); $perPage = (int) $request->input('per_page', 20);
@@ -51,6 +53,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -94,6 +97,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -146,6 +150,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -173,6 +178,7 @@ class LiveShowPhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(

View File

@@ -14,6 +14,7 @@ use App\Services\Packages\PackageUsageTracker;
use App\Services\Storage\EventStorageManager; use App\Services\Storage\EventStorageManager;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\ImageHelper; use App\Support\ImageHelper;
use App\Support\TenantMemberPermissions;
use App\Support\UploadStream; use App\Support\UploadStream;
use App\Support\WatermarkConfigResolver; use App\Support\WatermarkConfigResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
@@ -524,15 +525,8 @@ class PhotoController extends Controller
'alt_text' => ['sometimes', 'string', 'max:255'], 'alt_text' => ['sometimes', 'string', 'max:255'],
]); ]);
// Only tenant admins can moderate if (isset($validated['status'])) {
if (isset($validated['status']) && ! $this->tokenHasScope($request, 'tenant-admin')) { TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
return ApiError::response(
'insufficient_scope',
'Insufficient Scopes',
'You are not allowed to moderate photos for this event.',
Response::HTTP_FORBIDDEN,
['required_scope' => 'tenant-admin']
);
} }
$photo->update($validated); $photo->update($validated);
@@ -634,6 +628,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -657,6 +652,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
if ($photo->event_id !== $event->id) { if ($photo->event_id !== $event->id) {
return ApiError::response( return ApiError::response(
@@ -680,6 +676,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([ $request->validate([
'photo_ids' => 'required|array', 'photo_ids' => 'required|array',
@@ -725,6 +722,7 @@ class PhotoController extends Controller
$event = Event::where('slug', $eventSlug) $event = Event::where('slug', $eventSlug)
->where('tenant_id', $tenantId) ->where('tenant_id', $tenantId)
->firstOrFail(); ->firstOrFail();
TenantMemberPermissions::ensureEventPermission($request, $event, 'photos:moderate');
$request->validate([ $request->validate([
'photo_ids' => 'required|array', 'photo_ids' => 'required|array',

View File

@@ -11,6 +11,7 @@ use App\Models\Task;
use App\Models\TaskCollection; use App\Models\TaskCollection;
use App\Models\Tenant; use App\Models\Tenant;
use App\Support\ApiError; use App\Support\ApiError;
use App\Support\TenantMemberPermissions;
use App\Support\TenantRequestResolver; use App\Support\TenantRequestResolver;
use Illuminate\Http\JsonResponse; use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request; use Illuminate\Http\Request;
@@ -66,6 +67,8 @@ class TaskController extends Controller
*/ */
public function store(TaskStoreRequest $request): JsonResponse public function store(TaskStoreRequest $request): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request); $tenant = $this->currentTenant($request);
$collectionId = $request->input('collection_id'); $collectionId = $request->input('collection_id');
$collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null; $collection = $collectionId ? $this->resolveAccessibleCollection($request, $collectionId) : null;
@@ -107,6 +110,8 @@ class TaskController extends Controller
*/ */
public function update(TaskUpdateRequest $request, Task $task): JsonResponse public function update(TaskUpdateRequest $request, Task $task): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
$tenant = $this->currentTenant($request); $tenant = $this->currentTenant($request);
if ($task->tenant_id !== $tenant->id) { if ($task->tenant_id !== $tenant->id) {
@@ -138,6 +143,8 @@ class TaskController extends Controller
*/ */
public function destroy(Request $request, Task $task): JsonResponse public function destroy(Request $request, Task $task): JsonResponse
{ {
TenantMemberPermissions::ensureTenantPermission($request, 'tasks:manage');
if ($task->tenant_id !== $this->currentTenant($request)->id) { if ($task->tenant_id !== $this->currentTenant($request)->id) {
abort(404, 'Task nicht gefunden.'); abort(404, 'Task nicht gefunden.');
} }
@@ -154,6 +161,8 @@ class TaskController extends Controller
*/ */
public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse public function assignToEvent(Request $request, Task $task, Event $event): JsonResponse
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
if (($task->tenant_id && $task->tenant_id !== $tenantId) || $event->tenant_id !== $tenantId) { 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 public function bulkAssignToEvent(Request $request, Event $event): JsonResponse
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
@@ -230,6 +241,8 @@ class TaskController extends Controller
public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse public function bulkDetachFromEvent(Request $request, Event $event): JsonResponse
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {
@@ -256,6 +269,8 @@ class TaskController extends Controller
public function reorderForEvent(Request $request, Event $event): JsonResponse public function reorderForEvent(Request $request, Event $event): JsonResponse
{ {
TenantMemberPermissions::ensureEventPermission($request, $event, 'tasks:manage');
$tenantId = $this->currentTenant($request)->id; $tenantId = $this->currentTenant($request)->id;
if ($event->tenant_id !== $tenantId) { if ($event->tenant_id !== $tenantId) {

View File

@@ -3,6 +3,7 @@
namespace App\Http\Resources\Tenant; namespace App\Http\Resources\Tenant;
use App\Services\Packages\PackageLimitEvaluator; use App\Services\Packages\PackageLimitEvaluator;
use App\Support\TenantMemberPermissions;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource; use Illuminate\Http\Resources\Json\JsonResource;
use Illuminate\Http\Resources\MissingValue; use Illuminate\Http\Resources\MissingValue;
@@ -18,6 +19,12 @@ class EventResource extends JsonResource
$showSensitive = $this->tenant_id === $tenantId; $showSensitive = $this->tenant_id === $tenantId;
$settings = is_array($this->settings) ? $this->settings : []; $settings = is_array($this->settings) ? $this->settings : [];
$eventPackage = null; $eventPackage = null;
$memberPermissions = null;
$user = $request->user();
if ($user && $user->role === 'member') {
$memberPermissions = TenantMemberPermissions::resolveEventPermissions($request, $this->resource);
}
if ($this->relationLoaded('eventPackages')) { if ($this->relationLoaded('eventPackages')) {
$related = $this->getRelation('eventPackages'); $related = $this->getRelation('eventPackages');
@@ -86,6 +93,7 @@ class EventResource extends JsonResource
? $limitEvaluator->summarizeEventPackage($eventPackage) ? $limitEvaluator->summarizeEventPackage($eventPackage)
: null, : null,
'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [], 'addons' => $eventPackage ? $this->formatAddons($eventPackage) : [],
'member_permissions' => $memberPermissions,
]; ];
} }

View File

@@ -0,0 +1,174 @@
<?php
namespace App\Support;
use App\Models\Event;
use App\Models\EventMember;
use App\Models\User;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class TenantMemberPermissions
{
/**
* @return array<int, string>
*/
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<int, string>
*/
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<int, string>|string|null $permissions
* @return array<int, string>
*/
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<int, string> $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;
}
}

View File

@@ -116,6 +116,7 @@ export type TenantEvent = {
} | null; } | null;
limits?: EventLimitSummary | null; limits?: EventLimitSummary | null;
addons?: EventAddonSummary[]; addons?: EventAddonSummary[];
member_permissions?: string[] | null;
[key: string]: unknown; [key: string]: unknown;
}; };
@@ -933,6 +934,11 @@ function normalizeEvent(event: JsonValue): TenantEvent {
settings, settings,
package: event.package ?? null, package: event.package ?? null,
limits: (event.limits ?? null) as EventLimitSummary | 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; return normalized;

View File

@@ -33,13 +33,37 @@ type DeviceSetupProps = {
onOpenSettings: () => void; 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() { export default function MobileDashboardPage() {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
const { t, i18n } = useTranslation('management'); const { t, i18n } = useTranslation('management');
const { events, activeEvent, hasEvents, hasMultipleEvents, isLoading, selectEvent } = useEventContext(); 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<TenantEvent[]>([]); const [fallbackEvents, setFallbackEvents] = React.useState<TenantEvent[]>([]);
const [fallbackLoading, setFallbackLoading] = React.useState(false); const [fallbackLoading, setFallbackLoading] = React.useState(false);
const [fallbackAttempted, setFallbackAttempted] = React.useState(false); const [fallbackAttempted, setFallbackAttempted] = React.useState(false);
@@ -150,6 +174,7 @@ export default function MobileDashboardPage() {
const hasSummaryPackage = const hasSummaryPackage =
Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId); Boolean(activePackage?.id && activePackage.id !== summarySeenPackageId);
const shouldRedirectToBilling = const shouldRedirectToBilling =
!isMember &&
!packagesLoading && !packagesLoading &&
!packagesError && !packagesError &&
!effectiveHasEvents && !effectiveHasEvents &&
@@ -210,7 +235,7 @@ export default function MobileDashboardPage() {
closeTour(); closeTour();
navigate(adminPath('/mobile/events/new')); navigate(adminPath('/mobile/events/new'));
}, },
showAction: true, showAction: canManageEvents,
}, },
qr: { qr: {
key: 'qr', key: 'qr',
@@ -257,7 +282,7 @@ export default function MobileDashboardPage() {
}; };
return tourStepKeys.map((key) => stepMap[key]); return tourStepKeys.map((key) => stepMap[key]);
}, [closeTour, navigate, t, tourStepKeys, tourTargetSlug]); }, [canManageEvents, closeTour, navigate, t, tourStepKeys, tourTargetSlug]);
const activeTourStep = tourSteps[tourStep] ?? tourSteps[0]; const activeTourStep = tourSteps[tourStep] ?? tourSteps[0];
const totalTourSteps = tourSteps.length; const totalTourSteps = tourSteps.length;
@@ -356,6 +381,7 @@ export default function MobileDashboardPage() {
handleSummaryClose(); handleSummaryClose();
navigate(adminPath('/mobile/events/new')); navigate(adminPath('/mobile/events/new'));
}} }}
showContinue={canManageEvents}
packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')} packageName={activePackage.package_name ?? t('mobileDashboard.packageSummary.fallbackTitle', 'Package summary')}
packageType={activePackage.package_type ?? null} packageType={activePackage.package_type ?? null}
remainingEvents={remainingEvents} remainingEvents={remainingEvents}
@@ -462,11 +488,18 @@ export default function MobileDashboardPage() {
locale={locale} locale={locale}
canSwitch={effectiveMultiple} canSwitch={effectiveMultiple}
onSwitch={() => setEventSwitcherOpen(true)} 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
}
/> />
<EventManagementGrid <EventManagementGrid
event={activeEvent} event={activeEvent}
tasksEnabled={tasksEnabled} tasksEnabled={tasksEnabled}
isMember={isMember}
permissions={memberPermissions}
onNavigate={(path) => navigate(path)} onNavigate={(path) => navigate(path)}
/> />
<KpiStrip <KpiStrip
@@ -500,6 +533,7 @@ function PackageSummarySheet({
open, open,
onClose, onClose,
onContinue, onContinue,
showContinue,
packageName, packageName,
packageType, packageType,
remainingEvents, remainingEvents,
@@ -512,6 +546,7 @@ function PackageSummarySheet({
open: boolean; open: boolean;
onClose: () => void; onClose: () => void;
onContinue: () => void; onContinue: () => void;
showContinue: boolean;
packageName: string; packageName: string;
packageType: string | null; packageType: string | null;
remainingEvents: number | null | undefined; remainingEvents: number | null | undefined;
@@ -600,7 +635,9 @@ function PackageSummarySheet({
</MobileCard> </MobileCard>
<XStack space="$2"> <XStack space="$2">
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} /> {showContinue ? (
<CTAButton label={t('mobileDashboard.packageSummary.continue', 'Continue to event setup')} onPress={onContinue} fullWidth={false} />
) : null}
<CTAButton label={t('mobileDashboard.packageSummary.dismiss', 'Close')} tone="ghost" onPress={onClose} fullWidth={false} /> <CTAButton label={t('mobileDashboard.packageSummary.dismiss', 'Close')} tone="ghost" onPress={onClose} fullWidth={false} />
</XStack> </XStack>
</YStack> </YStack>
@@ -919,12 +956,19 @@ function OnboardingEmptyState({ installPrompt, pushState, devicePermissions, onO
<Text fontSize="$sm" color={text} opacity={0.9}> <Text fontSize="$sm" color={text} opacity={0.9}>
{t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')} {t('mobileDashboard.emptyBody', 'Print a QR, collect uploads, and start moderating in minutes.')}
</Text> </Text>
<CTAButton label={t('mobileDashboard.ctaCreate', 'Create event')} onPress={() => navigate(adminPath('/mobile/events/new'))} /> {canManageEvents ? (
<CTAButton <>
label={t('mobileDashboard.ctaWelcome', 'Start welcome journey')} <CTAButton
tone="ghost" label={t('mobileDashboard.ctaCreate', 'Create event')}
onPress={() => navigate(ADMIN_WELCOME_BASE_PATH)} onPress={() => navigate(adminPath('/mobile/events/new'))}
/> />
<CTAButton
label={t('mobileDashboard.ctaWelcome', 'Start welcome journey')}
tone="ghost"
onPress={() => navigate(ADMIN_WELCOME_BASE_PATH)}
/>
</>
) : null}
</YStack> </YStack>
</MobileCard> </MobileCard>
@@ -1156,13 +1200,15 @@ function EventHeaderCard({
locale, locale,
canSwitch, canSwitch,
onSwitch, onSwitch,
canEdit,
onEdit, onEdit,
}: { }: {
event: TenantEvent | null; event: TenantEvent | null;
locale: string; locale: string;
canSwitch: boolean; canSwitch: boolean;
onSwitch: () => void; onSwitch: () => void;
onEdit: () => void; canEdit: boolean;
onEdit?: () => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme(); const { textStrong, muted, border, surface, accentSoft, primary, shadow } = useAdminTheme();
@@ -1222,24 +1268,26 @@ function EventHeaderCard({
</XStack> </XStack>
</YStack> </YStack>
<Pressable {canEdit && onEdit ? (
aria-label={t('mobileEvents.edit', 'Edit event')} <Pressable
onPress={onEdit} aria-label={t('mobileEvents.edit', 'Edit event')}
style={{ onPress={onEdit}
position: 'absolute', style={{
right: 16, position: 'absolute',
top: 16, right: 16,
width: 44, top: 16,
height: 44, width: 44,
borderRadius: 22, height: 44,
backgroundColor: accentSoft, borderRadius: 22,
alignItems: 'center', backgroundColor: accentSoft,
justifyContent: 'center', alignItems: 'center',
boxShadow: '0 6px 16px rgba(0,0,0,0.12)', justifyContent: 'center',
}} boxShadow: '0 6px 16px rgba(0,0,0,0.12)',
> }}
<Pencil size={18} color={primary} /> >
</Pressable> <Pencil size={18} color={primary} />
</Pressable>
) : null}
</Card> </Card>
); );
} }
@@ -1247,10 +1295,14 @@ function EventHeaderCard({
function EventManagementGrid({ function EventManagementGrid({
event, event,
tasksEnabled, tasksEnabled,
isMember,
permissions,
onNavigate, onNavigate,
}: { }: {
event: TenantEvent | null; event: TenantEvent | null;
tasksEnabled: boolean; tasksEnabled: boolean;
isMember: boolean;
permissions: string[];
onNavigate: (path: string) => void; onNavigate: (path: string) => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
@@ -1263,81 +1315,103 @@ function EventManagementGrid({
} }
const tiles = [ const tiles = [
{ {
key: 'settings',
icon: Pencil, icon: Pencil,
label: t('mobileDashboard.shortcutSettings', 'Event settings'), label: t('mobileDashboard.shortcutSettings', 'Event settings'),
color: ADMIN_ACTION_COLORS.settings, color: ADMIN_ACTION_COLORS.settings,
requiredPermission: 'events:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/edit`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'tasks',
icon: Sparkles, icon: Sparkles,
label: tasksEnabled label: tasksEnabled
? t('events.quick.tasks', 'Tasks & Checklists') ? t('events.quick.tasks', 'Tasks & Checklists')
: `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`, : `${t('events.quick.tasks', 'Tasks & Checklists')} (${t('common:states.disabled', 'Disabled')})`,
color: ADMIN_ACTION_COLORS.tasks, color: ADMIN_ACTION_COLORS.tasks,
requiredPermission: 'tasks:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/tasks`)) : undefined,
disabled: !tasksEnabled || !slug, disabled: !tasksEnabled || !slug,
}, },
{ {
key: 'qr',
icon: QrCode, icon: QrCode,
label: t('events.quick.qr', 'QR Code Layouts'), label: t('events.quick.qr', 'QR Code Layouts'),
color: ADMIN_ACTION_COLORS.qr, color: ADMIN_ACTION_COLORS.qr,
requiredPermission: 'join-tokens:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/qr`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'images',
icon: ImageIcon, icon: ImageIcon,
label: t('events.quick.images', 'Image Management'), label: t('events.quick.images', 'Image Management'),
color: ADMIN_ACTION_COLORS.images, color: ADMIN_ACTION_COLORS.images,
requiredPermission: 'photos:moderate',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'controlRoom',
icon: Tv, icon: Tv,
label: t('events.quick.controlRoom', 'Moderation & Live Show'), label: t('events.quick.controlRoom', 'Moderation & Live Show'),
color: ADMIN_ACTION_COLORS.liveShow, color: ADMIN_ACTION_COLORS.liveShow,
requiredPermission: 'photos:moderate',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/control-room`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'liveShowSettings',
icon: Settings, icon: Settings,
label: t('events.quick.liveShowSettings', 'Live Show settings'), label: t('events.quick.liveShowSettings', 'Live Show settings'),
color: ADMIN_ACTION_COLORS.liveShowSettings, color: ADMIN_ACTION_COLORS.liveShowSettings,
requiredPermission: 'live-show:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/live-show/settings`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'members',
icon: Users, icon: Users,
label: t('events.quick.guests', 'Guest Management'), label: t('events.quick.guests', 'Guest Management'),
color: ADMIN_ACTION_COLORS.guests, color: ADMIN_ACTION_COLORS.guests,
requiredPermission: 'members:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/members`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'guestMessages',
icon: Megaphone, icon: Megaphone,
label: t('events.quick.guestMessages', 'Guest messages'), label: t('events.quick.guestMessages', 'Guest messages'),
color: ADMIN_ACTION_COLORS.guestMessages, color: ADMIN_ACTION_COLORS.guestMessages,
requiredPermission: 'guest-notifications:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/guest-notifications`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'branding',
icon: Layout, icon: Layout,
label: t('events.quick.branding', 'Branding & Theme'), label: t('events.quick.branding', 'Branding & Theme'),
color: ADMIN_ACTION_COLORS.branding, color: ADMIN_ACTION_COLORS.branding,
requiredPermission: 'events:manage',
onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined, onPress: slug && brandingAllowed ? () => onNavigate(adminPath(`/mobile/events/${slug}/branding`)) : undefined,
disabled: !brandingAllowed || !slug, disabled: !brandingAllowed || !slug,
}, },
{ {
key: 'photobooth',
icon: Camera, icon: Camera,
label: t('events.quick.photobooth', 'Photobooth'), label: t('events.quick.photobooth', 'Photobooth'),
color: ADMIN_ACTION_COLORS.photobooth, color: ADMIN_ACTION_COLORS.photobooth,
requiredPermission: 'events:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/photobooth`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
{ {
key: 'analytics',
icon: TrendingUp, icon: TrendingUp,
label: t('mobileDashboard.shortcutAnalytics', 'Analytics'), label: t('mobileDashboard.shortcutAnalytics', 'Analytics'),
color: ADMIN_ACTION_COLORS.analytics, color: ADMIN_ACTION_COLORS.analytics,
requiredPermission: 'events:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/analytics`)) : undefined,
disabled: !slug, disabled: !slug,
}, },
@@ -1345,16 +1419,22 @@ function EventManagementGrid({
if (event && isPastEvent(event.event_date)) { if (event && isPastEvent(event.event_date)) {
tiles.push({ tiles.push({
key: 'recap',
icon: Sparkles, icon: Sparkles,
label: t('events.quick.recap', 'Recap & Archive'), label: t('events.quick.recap', 'Recap & Archive'),
color: ADMIN_ACTION_COLORS.recap, color: ADMIN_ACTION_COLORS.recap,
requiredPermission: 'events:manage',
onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined, onPress: slug ? () => onNavigate(adminPath(`/mobile/events/${slug}/recap`)) : undefined,
disabled: !slug, disabled: !slug,
}); });
} }
const visibleTiles = isMember
? tiles.filter((tile) => !tile.requiredPermission || allowPermission(permissions, tile.requiredPermission))
: tiles;
const rows: typeof tiles[] = []; const rows: typeof tiles[] = [];
tiles.forEach((tile, index) => { visibleTiles.forEach((tile, index) => {
const rowIndex = Math.floor(index / 2); const rowIndex = Math.floor(index / 2);
if (!rows[rowIndex]) { if (!rows[rowIndex]) {
rows[rowIndex] = []; rows[rowIndex] = [];

View File

@@ -36,6 +36,7 @@ import toast from 'react-hot-toast';
import { useBackNavigation } from './hooks/useBackNavigation'; import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { useOnlineStatus } from './hooks/useOnlineStatus'; import { useOnlineStatus } from './hooks/useOnlineStatus';
import { useAuth } from '../auth/context';
import { import {
enqueuePhotoAction, enqueuePhotoAction,
loadPhotoQueue, loadPhotoQueue,
@@ -91,6 +92,8 @@ export default function MobileEventControlRoomPage() {
const location = useLocation(); const location = useLocation();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { activeEvent, selectEvent } = useEventContext(); const { activeEvent, selectEvent } = useEventContext();
const { user } = useAuth();
const isMember = user?.role === 'member';
const slug = slugParam ?? activeEvent?.slug ?? null; const slug = slugParam ?? activeEvent?.slug ?? null;
const online = useOnlineStatus(); const online = useOnlineStatus();
const { textStrong, text, muted, border, accentSoft, accent, danger } = useAdminTheme(); const { textStrong, text, muted, border, accentSoft, accent, danger } = useAdminTheme();
@@ -574,7 +577,7 @@ export default function MobileEventControlRoomPage() {
> >
<RefreshCcw size={18} color={textStrong} /> <RefreshCcw size={18} color={textStrong} />
</HeaderActionButton> </HeaderActionButton>
{slug ? ( {slug && !isMember ? (
<HeaderActionButton <HeaderActionButton
onPress={() => navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))} onPress={() => navigate(adminPath(`/mobile/events/${slug}/live-show/settings`))}
ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')} ariaLabel={t('events.quick.liveShowSettings', 'Live Show settings')}

View File

@@ -20,10 +20,13 @@ import { useBackNavigation } from './hooks/useBackNavigation';
import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters'; import { buildEventStatusCounts, filterEventsByStatus, resolveEventStatusKey, type EventStatusKey } from './lib/eventFilters';
import { buildEventListStats } from './lib/eventListStats'; import { buildEventListStats } from './lib/eventListStats';
import { useAdminTheme } from './theme'; import { useAdminTheme } from './theme';
import { useAuth } from '../auth/context';
export default function MobileEventsPage() { export default function MobileEventsPage() {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const isMember = user?.role === 'member';
const [events, setEvents] = React.useState<TenantEvent[]>([]); const [events, setEvents] = React.useState<TenantEvent[]>([]);
const [loading, setLoading] = React.useState(true); const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null); const [error, setError] = React.useState<string | null>(null);
@@ -139,7 +142,9 @@ export default function MobileEventsPage() {
<Text fontSize="$sm" color={muted} textAlign="center"> <Text fontSize="$sm" color={muted} textAlign="center">
{t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')} {t('events.list.empty.description', 'Starte jetzt mit deinem ersten Event.')}
</Text> </Text>
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} /> {!isMember ? (
<CTAButton label={t('events.actions.create', 'Create New Event')} onPress={() => navigate(adminPath('/events/new'))} />
) : null}
</YStack> </YStack>
</Card> </Card>
) : ( ) : (
@@ -149,15 +154,17 @@ export default function MobileEventsPage() {
statusFilter={statusFilter} statusFilter={statusFilter}
onStatusChange={setStatusFilter} onStatusChange={setStatusFilter}
onOpen={(slug) => navigate(adminPath(`/mobile/events/${slug}`))} 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}
/> />
)} )}
<FloatingActionButton {!isMember ? (
label={t('events.actions.create', 'Create New Event')} <FloatingActionButton
icon={Plus} label={t('events.actions.create', 'Create New Event')}
onPress={() => navigate(adminPath('/mobile/events/new'))} icon={Plus}
/> onPress={() => navigate(adminPath('/mobile/events/new'))}
/>
) : null}
</MobileShell> </MobileShell>
); );
} }
@@ -175,7 +182,7 @@ function EventsList({
statusFilter: EventStatusKey; statusFilter: EventStatusKey;
onStatusChange: (value: EventStatusKey) => void; onStatusChange: (value: EventStatusKey) => void;
onOpen: (slug: string) => void; onOpen: (slug: string) => void;
onEdit: (slug: string) => void; onEdit?: (slug: string) => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme(); const { text, muted, subtle, border, primary, surface, surfaceMuted, accentSoft, accent, shadow } = useAdminTheme();
@@ -350,7 +357,7 @@ function EventRow({
statusLabel: string; statusLabel: string;
statusTone: 'success' | 'warning' | 'muted'; statusTone: 'success' | 'warning' | 'muted';
onOpen: (slug: string) => void; onOpen: (slug: string) => void;
onEdit: (slug: string) => void; onEdit?: (slug: string) => void;
}) { }) {
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const stats = buildEventListStats(event); const stats = buildEventListStats(event);
@@ -386,11 +393,13 @@ function EventRow({
</XStack> </XStack>
<PillBadge tone={statusTone}>{statusLabel}</PillBadge> <PillBadge tone={statusTone}>{statusLabel}</PillBadge>
</YStack> </YStack>
<Pressable onPress={() => onEdit(event.slug)}> {onEdit ? (
<Text fontSize="$xl" color={muted}> <Pressable onPress={() => onEdit(event.slug)}>
˅ <Text fontSize="$xl" color={muted}>
</Text> ˅
</Pressable> </Text>
</Pressable>
) : null}
</XStack> </XStack>
<XStack alignItems="center" space="$2" flexWrap="wrap"> <XStack alignItems="center" space="$2" flexWrap="wrap">

View File

@@ -22,6 +22,7 @@ import { useAdminTheme } from './theme';
export default function MobileProfilePage() { export default function MobileProfilePage() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const isMember = user?.role === 'member';
const navigate = useNavigate(); const navigate = useNavigate();
const { t } = useTranslation('management'); const { t } = useTranslation('management');
const { appearance, updateAppearance } = useAppearance(); const { appearance, updateAppearance } = useAppearance();
@@ -130,51 +131,55 @@ export default function MobileProfilePage() {
onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)} onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}
/> />
</YGroup.Item> </YGroup.Item>
<YGroup.Item> {!isMember ? (
<ListItem <>
hoverTheme <YGroup.Item>
pressTheme <ListItem
paddingVertical="$2" hoverTheme
paddingHorizontal="$3" pressTheme
title={ paddingVertical="$2"
<Text fontSize="$sm" color={textColor}> paddingHorizontal="$3"
{t('billing.sections.packages.title', 'Packages & Billing')} title={
</Text> <Text fontSize="$sm" color={textColor}>
} {t('billing.sections.packages.title', 'Packages & Billing')}
iconAfter={<Settings size={18} color={subtle} />} </Text>
onPress={() => navigate(adminPath('/mobile/billing#packages'))} }
/> iconAfter={<Settings size={18} color={subtle} />}
</YGroup.Item> onPress={() => navigate(adminPath('/mobile/billing#packages'))}
<YGroup.Item> />
<ListItem </YGroup.Item>
hoverTheme <YGroup.Item>
pressTheme <ListItem
paddingVertical="$2" hoverTheme
paddingHorizontal="$3" pressTheme
title={ paddingVertical="$2"
<Text fontSize="$sm" color={textColor}> paddingHorizontal="$3"
{t('billing.sections.invoices.title', 'Invoices & Payments')} title={
</Text> <Text fontSize="$sm" color={textColor}>
} {t('billing.sections.invoices.title', 'Invoices & Payments')}
iconAfter={<Settings size={18} color={subtle} />} </Text>
onPress={() => navigate(adminPath('/mobile/billing#invoices'))} }
/> iconAfter={<Settings size={18} color={subtle} />}
</YGroup.Item> onPress={() => navigate(adminPath('/mobile/billing#invoices'))}
<YGroup.Item> />
<ListItem </YGroup.Item>
hoverTheme <YGroup.Item>
pressTheme <ListItem
paddingVertical="$2" hoverTheme
paddingHorizontal="$3" pressTheme
title={ paddingVertical="$2"
<Text fontSize="$sm" color={textColor}> paddingHorizontal="$3"
{t('dataExports.title', 'Data exports')} title={
</Text> <Text fontSize="$sm" color={textColor}>
} {t('dataExports.title', 'Data exports')}
iconAfter={<Download size={18} color={subtle} />} </Text>
onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)} }
/> iconAfter={<Download size={18} color={subtle} />}
</YGroup.Item> onPress={() => navigate(ADMIN_DATA_EXPORTS_PATH)}
/>
</YGroup.Item>
</>
) : null}
</YGroup> </YGroup>
</YStack> </YStack>
</Card> </Card>

View File

@@ -14,6 +14,7 @@ const fixtures = vi.hoisted(() => ({
photo_count: 12, photo_count: 12,
active_invites_count: 3, active_invites_count: 3,
total_invites_count: 5, total_invites_count: 5,
member_permissions: ['photos:moderate', 'tasks:manage', 'join-tokens:manage'],
}, },
activePackage: { activePackage: {
id: 1, id: 1,
@@ -36,6 +37,10 @@ const fixtures = vi.hoisted(() => ({
})); }));
const navigateMock = vi.fn(); const navigateMock = vi.fn();
const authState = {
status: 'authenticated',
user: { role: 'tenant_admin' },
};
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock, useNavigate: () => navigateMock,
@@ -103,7 +108,7 @@ vi.mock('../../context/EventContext', () => ({
})); }));
vi.mock('../../auth/context', () => ({ vi.mock('../../auth/context', () => ({
useAuth: () => ({ status: 'unauthenticated' }), useAuth: () => authState,
})); }));
vi.mock('../hooks/useInstallPrompt', () => ({ vi.mock('../hooks/useInstallPrompt', () => ({
@@ -232,4 +237,16 @@ describe('MobileDashboardPage', () => {
expect(screen.getByText('2 of 5 events used')).toBeInTheDocument(); expect(screen.getByText('2 of 5 events used')).toBeInTheDocument();
expect(screen.getByText('3 remaining')).toBeInTheDocument(); expect(screen.getByText('3 remaining')).toBeInTheDocument();
}); });
it('hides admin-only shortcuts for members', () => {
authState.user = { role: 'member' };
render(<MobileDashboardPage />);
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' };
});
}); });

View File

@@ -3,6 +3,9 @@ import { describe, expect, it, vi } from 'vitest';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
const navigateMock = vi.fn(); const navigateMock = vi.fn();
const authState = {
user: { role: 'tenant_admin' },
};
vi.mock('react-router-dom', () => ({ vi.mock('react-router-dom', () => ({
useNavigate: () => navigateMock, useNavigate: () => navigateMock,
@@ -38,6 +41,10 @@ vi.mock('../../auth/tokens', () => ({
isAuthError: () => false, isAuthError: () => false,
})); }));
vi.mock('../../auth/context', () => ({
useAuth: () => authState,
}));
vi.mock('../../lib/apiError', () => ({ vi.mock('../../lib/apiError', () => ({
getApiErrorMessage: () => 'error', getApiErrorMessage: () => 'error',
})); }));
@@ -133,4 +140,15 @@ describe('MobileEventsPage', () => {
expect(screen.getByText('Status')).toBeInTheDocument(); expect(screen.getByText('Status')).toBeInTheDocument();
expect(screen.getByText('Demo Event')).toBeInTheDocument(); expect(screen.getByText('Demo Event')).toBeInTheDocument();
}); });
it('hides create actions for members', async () => {
authState.user = { role: 'member' };
render(<MobileEventsPage />);
expect(await screen.findByText('Demo Event')).toBeInTheDocument();
expect(screen.queryByText('Create New Event')).not.toBeInTheDocument();
authState.user = { role: 'tenant_admin' };
});
}); });

View File

@@ -19,6 +19,7 @@ import { setTabHistory } from '../lib/tabHistory';
import { loadPhotoQueue } from '../lib/photoModerationQueue'; import { loadPhotoQueue } from '../lib/photoModerationQueue';
import { countQueuedPhotoActions } from '../lib/queueStatus'; import { countQueuedPhotoActions } from '../lib/queueStatus';
import { useAdminTheme } from '../theme'; import { useAdminTheme } from '../theme';
import { useAuth } from '../../auth/context';
type MobileShellProps = { type MobileShellProps = {
title?: string; title?: string;
@@ -31,6 +32,7 @@ type MobileShellProps = {
export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) { export function MobileShell({ title, subtitle, children, activeTab, onBack, headerActions }: MobileShellProps) {
const { events, activeEvent, selectEvent } = useEventContext(); const { events, activeEvent, selectEvent } = useEventContext();
const { user } = useAuth();
const { go } = useMobileNav(activeEvent?.slug, activeTab); const { go } = useMobileNav(activeEvent?.slug, activeTab);
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); 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 pageTitle = title ?? t('header.appName', 'Event Admin');
const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null; const eventContext = !isCompactHeader && effectiveActive ? resolveEventDisplayName(effectiveActive) : null;
const subtitleText = subtitle ?? eventContext ?? ''; 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 ? ( const headerBackButton = onBack ? (
<HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}> <HeaderActionButton onPress={onBack} ariaLabel={t('actions.back', 'Back')}>
<XStack alignItems="center" space="$1.5"> <XStack alignItems="center" space="$1.5">

View File

@@ -52,6 +52,10 @@ vi.mock('../../../context/EventContext', () => ({
}), }),
})); }));
vi.mock('../../../auth/context', () => ({
useAuth: () => ({ user: { role: 'tenant_admin' } }),
}));
vi.mock('../../hooks/useMobileNav', () => ({ vi.mock('../../hooks/useMobileNav', () => ({
useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }), useMobileNav: () => ({ go: vi.fn(), slug: 'event-1' }),
})); }));

View File

@@ -198,9 +198,9 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/branding', element: <RequireAdminAccess><MobileBrandingPage /></RequireAdminAccess> },
{ path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> }, { path: 'mobile/events/new', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/edit', element: <RequireAdminAccess><MobileEventFormPage /></RequireAdminAccess> },
{ path: 'mobile/events/:slug/qr', element: <RequireAdminAccess><MobileQrPrintPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/qr', element: <MobileQrPrintPage /> },
{ path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <RequireAdminAccess><MobileQrLayoutCustomizePage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/qr/customize/:tokenId?', element: <MobileQrLayoutCustomizePage /> },
{ path: 'mobile/events/:slug/control-room', element: <RequireAdminAccess><MobileEventControlRoomPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/control-room', element: <MobileEventControlRoomPage /> },
{ path: 'mobile/events/:slug/photos/:photoId?', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'mobile/events/:slug/photos/:photoId?', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
{ path: 'mobile/events/:slug/live-show', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> }, { path: 'mobile/events/:slug/live-show', element: <RedirectToMobileEvent buildPath={(slug) => `${ADMIN_EVENTS_PATH}/${slug}/control-room`} /> },
{ path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/live-show/settings', element: <RequireAdminAccess><MobileEventLiveShowSettingsPage /></RequireAdminAccess> },
@@ -212,8 +212,8 @@ export const router = createBrowserRouter([
{ path: 'mobile/events/:slug/guest-notifications', element: <RequireAdminAccess><MobileEventGuestNotificationsPage /></RequireAdminAccess> }, { path: 'mobile/events/:slug/guest-notifications', element: <RequireAdminAccess><MobileEventGuestNotificationsPage /></RequireAdminAccess> },
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> }, { path: 'mobile/notifications', element: <MobileNotificationsPage /> },
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> }, { path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> }, { path: 'mobile/profile', element: <MobileProfilePage /> },
{ path: 'mobile/profile/account', element: <RequireAdminAccess><MobileProfileAccountPage /></RequireAdminAccess> }, { path: 'mobile/profile/account', element: <MobileProfileAccountPage /> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> }, { path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> }, { path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> }, { path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },

View File

@@ -0,0 +1,95 @@
<?php
namespace Tests\Unit;
use App\Models\Event;
use App\Models\EventMember;
use App\Models\Tenant;
use App\Models\User;
use App\Support\TenantMemberPermissions;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Tests\TestCase;
class TenantMemberPermissionsTest extends TestCase
{
use RefreshDatabase;
public function test_resolves_permissions_for_member(): 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: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');
}
}