Enforce tenant member permissions
This commit is contained in:
@@ -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([
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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([
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
174
app/Support/TenantMemberPermissions.php
Normal file
174
app/Support/TenantMemberPermissions.php
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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] = [];
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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' };
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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' }),
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -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> },
|
||||||
|
|||||||
95
tests/Unit/TenantMemberPermissionsTest.php
Normal file
95
tests/Unit/TenantMemberPermissionsTest.php
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user