From 7ec3db9c59c3c938d8c5d3205c2ed4aed3e8630f Mon Sep 17 00:00:00 2001 From: Codex Agent Date: Sun, 9 Nov 2025 22:24:40 +0100 Subject: [PATCH] added "members" for an event that help the admins to moderate. members must be invited via email. --- .../Api/Tenant/EventMemberController.php | 195 ++++++++++++++++++ .../Api/Tenant/TenantAdminTokenController.php | 51 +++-- .../Middleware/EnsureTenantAdminToken.php | 43 +++- .../EnsureTenantCollaboratorToken.php | 33 +++ .../Tenant/EventMemberInviteRequest.php | 30 +++ .../Resources/Tenant/EventMemberResource.php | 32 +++ app/Models/Event.php | 5 + app/Models/EventMember.php | 57 +++++ app/Models/Tenant.php | 5 + app/Models/User.php | 34 +-- app/Support/TenantAuth.php | 4 +- bootstrap/app.php | 2 + database/factories/EventMemberFactory.php | 77 +++++++ ...1_09_205320_create_event_members_table.php | 43 ++++ resources/js/admin/api.ts | 15 +- resources/js/admin/auth/context.tsx | 18 +- resources/js/admin/components/AdminLayout.tsx | 16 +- resources/js/admin/components/UserMenu.tsx | 22 +- resources/js/admin/pages/EventMembersPage.tsx | 10 +- resources/js/admin/pages/LoginPage.tsx | 18 +- resources/js/admin/router.tsx | 38 ++-- routes/api.php | 108 ++++++---- .../Tenant/EventMemberControllerTest.php | 81 ++++++++ 23 files changed, 836 insertions(+), 101 deletions(-) create mode 100644 app/Http/Controllers/Api/Tenant/EventMemberController.php create mode 100644 app/Http/Middleware/EnsureTenantCollaboratorToken.php create mode 100644 app/Http/Requests/Tenant/EventMemberInviteRequest.php create mode 100644 app/Http/Resources/Tenant/EventMemberResource.php create mode 100644 app/Models/EventMember.php create mode 100644 database/factories/EventMemberFactory.php create mode 100644 database/migrations/2025_11_09_205320_create_event_members_table.php create mode 100644 tests/Feature/Tenant/EventMemberControllerTest.php diff --git a/app/Http/Controllers/Api/Tenant/EventMemberController.php b/app/Http/Controllers/Api/Tenant/EventMemberController.php new file mode 100644 index 0000000..1848074 --- /dev/null +++ b/app/Http/Controllers/Api/Tenant/EventMemberController.php @@ -0,0 +1,195 @@ +assertEventTenant($request, $event); + + /** @var LengthAwarePaginator $members */ + $members = $event->members() + ->orderByDesc('created_at') + ->paginate($request->integer('per_page', 25)); + + return EventMemberResource::collection($members)->response(); + } + + public function store(EventMemberInviteRequest $request, Event $event): JsonResponse + { + $this->assertEventTenant($request, $event); + + $data = $request->validated(); + $tenant = $this->resolveTenantFromRequest($request); + + if (! $tenant) { + throw ValidationException::withMessages([ + 'tenant_id' => __('Tenant context missing for invitation.'), + ]); + } + + $email = Str::lower($data['email']); + $role = $data['role']; + $name = $data['name'] ?? null; + + $user = $this->findOrCreateUser($tenant, $email, $name, $role); + + if ((int) $user->tenant_id !== (int) $tenant->id) { + throw ValidationException::withMessages([ + 'email' => __('Dieser Benutzer gehört bereits zu einem anderen Mandanten.'), + ]); + } + + $existing = EventMember::query() + ->where('event_id', $event->id) + ->where('email', $email) + ->first(); + + $member = EventMember::updateOrCreate( + [ + 'event_id' => $event->id, + 'email' => $email, + ], + [ + 'tenant_id' => $tenant->id, + 'user_id' => $user->id, + 'invited_by' => $request->user()?->id, + 'name' => $name ?? $user->name ?? $email, + 'role' => $role, + 'status' => $user->email_verified_at ? 'active' : 'invited', + 'invite_token' => $this->ensureInviteToken($event, $email), + 'invited_at' => $existing?->invited_at ?? now(), + 'joined_at' => $user->email_verified_at ? ($existing?->joined_at ?? now()) : null, + 'permissions' => $this->defaultPermissionsForRole($role), + ] + ); + + $member->refresh(); + + return (new EventMemberResource($member))->response()->setStatusCode(201); + } + + public function destroy(Request $request, Event $event, EventMember $member): JsonResponse + { + $this->assertEventTenant($request, $event); + + if ((int) $member->event_id !== (int) $event->id) { + throw ValidationException::withMessages([ + 'member_id' => __('Dieses Mitglied gehört nicht zu diesem Event.'), + ]); + } + + $member->delete(); + + return response()->json(['ok' => true]); + } + + private function assertEventTenant(Request $request, Event $event): void + { + $tenantId = $request->attributes->get('tenant_id'); + if ($tenantId === null || (int) $event->tenant_id !== (int) $tenantId) { + abort(403, 'Event belongs to a different tenant.'); + } + } + + private function resolveTenantFromRequest(Request $request): ?Tenant + { + $tenant = $request->attributes->get('tenant'); + + if ($tenant instanceof Tenant) { + return $tenant; + } + + $tenantId = $request->attributes->get('tenant_id'); + + return $tenantId ? Tenant::query()->find($tenantId) : null; + } + + private function findOrCreateUser(Tenant $tenant, string $email, ?string $name, string $role): User + { + $user = User::query()->where('email', $email)->first(); + + if (! $user) { + $user = new User; + $user->email = $email; + $user->password = Hash::make(Str::random(32)); + } + + if ($user->tenant_id && (int) $user->tenant_id !== (int) $tenant->id && $user->role !== 'super_admin') { + throw ValidationException::withMessages([ + 'email' => __('Dieser Benutzer ist einem anderen Mandanten zugeordnet.'), + ]); + } + + $user->tenant_id = $tenant->id; + + if ($role === 'tenant_admin' && $user->role !== 'super_admin') { + $user->role = 'tenant_admin'; + } elseif (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { + $user->role = 'member'; + } + + if ($name) { + $user->name = $name; + [$first, $last] = $this->splitName($name); + $user->first_name = $first; + $user->last_name = $last; + } + + if (! $user->exists) { + $user->email_verified_at = null; + } + + $user->save(); + + return $user; + } + + private function splitName(string $name): array + { + $parts = preg_split('/\s+/', trim($name)); + $first = Arr::first($parts) ?? $name; + $last = count($parts) > 1 ? Arr::last($parts) : null; + + return [$first, $last]; + } + + private function ensureInviteToken(Event $event, string $email): string + { + $existing = EventMember::query() + ->where('event_id', $event->id) + ->where('email', $email) + ->value('invite_token'); + + return $existing ?? Str::random(40); + } + + private function defaultPermissionsForRole(string $role): array + { + if ($role === 'tenant_admin') { + return ['*']; + } + + return [ + 'photos:moderate', + 'tasks:manage', + 'join-tokens:manage', + ]; + } +} diff --git a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php index 9088664..3bf946f 100644 --- a/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php +++ b/app/Http/Controllers/Api/Tenant/TenantAdminTokenController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api\Tenant; use App\Http\Controllers\Controller; use App\Http\Requests\Auth\TenantAdminTokenRequest; +use App\Models\EventMember; use App\Models\Tenant; use App\Models\User; use Illuminate\Http\JsonResponse; @@ -33,11 +34,7 @@ class TenantAdminTokenController extends Controller ]); } - if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { - throw ValidationException::withMessages([ - 'login' => [trans('auth.not_authorized')], - ]); - } + $this->ensureUserCanAccessPanel($user); if ($user->email_verified_at === null) { throw ValidationException::withMessages([ @@ -162,12 +159,7 @@ class TenantAdminTokenController extends Controller return response()->noContent(); } - if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { - return response()->json([ - 'error' => 'forbidden', - 'message' => trans('auth.not_authorized'), - ], 403); - } + $this->ensureUserCanAccessPanel($user); if ($user->email_verified_at === null) { return response()->json([ @@ -197,12 +189,16 @@ class TenantAdminTokenController extends Controller */ private function resolveTokenAbilities(User $user): array { - $abilities = ['tenant-admin']; + $abilities = ['tenant-member']; if ($user->tenant_id) { $abilities[] = 'tenant:'.$user->tenant_id; } + if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { + $abilities[] = 'tenant-admin'; + } + if ($user->role === 'super_admin') { $abilities[] = 'super-admin'; } @@ -222,4 +218,35 @@ class TenantAdminTokenController extends Controller return [$token->plainTextToken, $abilities]; } + + private function ensureUserCanAccessPanel(User $user): void + { + if (in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { + return; + } + + if ($user->role === 'member' && $this->userHasCollaboratorMembership($user)) { + return; + } + + throw ValidationException::withMessages([ + 'login' => [trans('auth.not_authorized')], + ]); + } + + private function userHasCollaboratorMembership(User $user): bool + { + if (! $user->tenant_id) { + return false; + } + + return EventMember::query() + ->where('tenant_id', $user->tenant_id) + ->where(function ($query) use ($user) { + $query->where('user_id', $user->id) + ->orWhere('email', $user->email); + }) + ->whereIn('status', ['active', 'invited']) + ->exists(); + } } diff --git a/app/Http/Middleware/EnsureTenantAdminToken.php b/app/Http/Middleware/EnsureTenantAdminToken.php index df621cb..d0586b1 100644 --- a/app/Http/Middleware/EnsureTenantAdminToken.php +++ b/app/Http/Middleware/EnsureTenantAdminToken.php @@ -3,6 +3,7 @@ namespace App\Http\Middleware; use App\Models\Tenant; +use App\Models\User; use App\Support\ApiError; use Closure; use Illuminate\Http\JsonResponse; @@ -30,12 +31,12 @@ class EnsureTenantAdminToken return $this->unauthorizedResponse('Missing personal access token context.'); } - if (! in_array($user->role, ['tenant_admin', 'super_admin'], true)) { - return $this->forbiddenResponse('Only tenant administrators may access this resource.'); + if (! in_array($user->role, $this->allowedRoles(), true)) { + return $this->forbiddenResponse($this->forbiddenRoleMessage()); } - if (! $accessToken->can('tenant-admin')) { - return $this->forbiddenResponse('Access token does not include the tenant-admin ability.'); + if (! $this->hasRequiredAbilities($accessToken, $user)) { + return $this->forbiddenResponse($this->abilityErrorMessage()); } /** @var Tenant|null $tenant */ @@ -90,6 +91,40 @@ class EnsureTenantAdminToken ); } + /** + * @return array + */ + protected function allowedRoles(): array + { + return ['tenant_admin', 'super_admin', 'admin']; + } + + protected function forbiddenRoleMessage(): string + { + return 'Only tenant administrators may access this resource.'; + } + + protected function requiredAbilitiesForUser(User $user): array + { + return ['tenant-admin']; + } + + protected function abilityErrorMessage(): string + { + return 'Access token does not include the tenant-admin ability.'; + } + + protected function hasRequiredAbilities(PersonalAccessToken $accessToken, User $user): bool + { + foreach ($this->requiredAbilitiesForUser($user) as $ability) { + if (! $accessToken->can($ability)) { + return false; + } + } + + return true; + } + private function resolveRequestedTenantId(Request $request): ?int { $routeTenant = $request->route('tenant'); diff --git a/app/Http/Middleware/EnsureTenantCollaboratorToken.php b/app/Http/Middleware/EnsureTenantCollaboratorToken.php new file mode 100644 index 0000000..df474c6 --- /dev/null +++ b/app/Http/Middleware/EnsureTenantCollaboratorToken.php @@ -0,0 +1,33 @@ +can('tenant-admin')) { + return true; + } + + return $accessToken->can('tenant-member'); + } +} diff --git a/app/Http/Requests/Tenant/EventMemberInviteRequest.php b/app/Http/Requests/Tenant/EventMemberInviteRequest.php new file mode 100644 index 0000000..df28cbb --- /dev/null +++ b/app/Http/Requests/Tenant/EventMemberInviteRequest.php @@ -0,0 +1,30 @@ +|string> + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email:rfc', 'max:255'], + 'name' => ['nullable', 'string', 'max:255'], + 'role' => ['required', 'string', 'in:tenant_admin,member'], + ]; + } +} diff --git a/app/Http/Resources/Tenant/EventMemberResource.php b/app/Http/Resources/Tenant/EventMemberResource.php new file mode 100644 index 0000000..43df99b --- /dev/null +++ b/app/Http/Resources/Tenant/EventMemberResource.php @@ -0,0 +1,32 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'name' => $this->name ?? $this->user?->full_name ?? $this->email, + 'email' => $this->email, + 'role' => $this->role, + 'status' => $this->status, + 'joined_at' => $this->joined_at, + 'invited_at' => $this->invited_at, + 'avatar_url' => $this->user?->profile_photo_url ?? null, + 'permissions' => $this->permissions, + 'user_id' => $this->user_id, + ]; + } +} diff --git a/app/Models/Event.php b/app/Models/Event.php index 422f2ba..64e6406 100644 --- a/app/Models/Event.php +++ b/app/Models/Event.php @@ -107,6 +107,11 @@ class Event extends Model return $this->hasMany(EventJoinToken::class); } + public function members(): HasMany + { + return $this->hasMany(EventMember::class); + } + public function hasActivePackage(): bool { return $this->eventPackage && $this->eventPackage->isActive(); diff --git a/app/Models/EventMember.php b/app/Models/EventMember.php new file mode 100644 index 0000000..a26afbf --- /dev/null +++ b/app/Models/EventMember.php @@ -0,0 +1,57 @@ +|null $permissions */ +class EventMember extends Model +{ + /** @use HasFactory<\Database\Factories\EventMemberFactory> */ + use HasFactory; + + protected $fillable = [ + 'tenant_id', + 'event_id', + 'user_id', + 'invited_by', + 'name', + 'email', + 'role', + 'status', + 'invite_token', + 'invited_at', + 'joined_at', + 'last_activity_at', + 'permissions', + ]; + + protected $casts = [ + 'invited_at' => 'datetime', + 'joined_at' => 'datetime', + 'last_activity_at' => 'datetime', + 'permissions' => 'array', + ]; + + public function tenant(): BelongsTo + { + return $this->belongsTo(Tenant::class); + } + + public function event(): BelongsTo + { + return $this->belongsTo(Event::class); + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } + + public function invitedBy(): BelongsTo + { + return $this->belongsTo(User::class, 'invited_by'); + } +} diff --git a/app/Models/Tenant.php b/app/Models/Tenant.php index 37a0f1c..48d5d8b 100644 --- a/app/Models/Tenant.php +++ b/app/Models/Tenant.php @@ -49,6 +49,11 @@ class Tenant extends Model ); } + public function members(): HasMany + { + return $this->hasMany(EventMember::class); + } + public function purchases(): HasMany { return $this->hasMany(PackagePurchase::class); diff --git a/app/Models/User.php b/app/Models/User.php index 731180a..55a46ef 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -3,21 +3,22 @@ namespace App\Models; // use Illuminate\Contracts\Auth\MustVerifyEmail; +use Filament\Models\Contracts\FilamentUser; +use Filament\Models\Contracts\HasName; +use Filament\Models\Contracts\HasTenants as FilamentHasTenants; +use Filament\Panel; use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Foundation\Auth\User as Authenticatable; -use Illuminate\Notifications\Notifiable; -use Laravel\Sanctum\HasApiTokens; -use Filament\Models\Contracts\FilamentUser; -use Filament\Models\Contracts\HasTenants as FilamentHasTenants; -use Filament\Panel; -use Illuminate\Support\Collection; use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; -use Filament\Models\Contracts\HasName; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Foundation\Auth\User as Authenticatable; +use Illuminate\Notifications\Notifiable; +use Illuminate\Support\Collection; +use Laravel\Sanctum\HasApiTokens; -class User extends Authenticatable implements MustVerifyEmail, HasName, FilamentUser, FilamentHasTenants +class User extends Authenticatable implements FilamentHasTenants, FilamentUser, HasName, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ use HasApiTokens, HasFactory, Notifiable; @@ -38,6 +39,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament 'address', 'phone', 'role', + 'tenant_id', 'pending_purchase', ]; @@ -77,7 +79,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament if (isset($credentials['login'])) { $login = $credentials['login']; $query->where('email', $login) - ->orWhere('username', $login); + ->orWhere('username', $login); } else { foreach ($this->getAuthIdentifiers() as $key => $value) { $query->where($key, $value); @@ -93,15 +95,16 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament protected function fullName(): Attribute { return Attribute::make( - get: fn () => trim(($this->first_name ?? '') . ' ' . ($this->last_name ?? '')) ?: $this->name, + get: fn () => trim(($this->first_name ?? '').' '.($this->last_name ?? '')) ?: $this->name, ); } public function getFilamentName(): string { if ($this->first_name && $this->last_name) { - return trim($this->first_name . ' ' . $this->last_name); + return trim($this->first_name.' '.$this->last_name); } + return $this->username ?? $this->email ?? 'Unnamed User'; } @@ -138,7 +141,7 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament return (int) $tenant->getKey() === (int) $ownedTenant->getKey(); } - public function getTenants(Panel $panel): array | Collection + public function getTenants(Panel $panel): array|Collection { if ($this->role === 'super_admin') { return Tenant::query()->orderBy('name')->get(); @@ -148,4 +151,9 @@ class User extends Authenticatable implements MustVerifyEmail, HasName, Filament return $tenant ? collect([$tenant]) : collect(); } + + public function eventMemberships(): HasMany + { + return $this->hasMany(EventMember::class); + } } diff --git a/app/Support/TenantAuth.php b/app/Support/TenantAuth.php index 56c3e9e..82d5d39 100644 --- a/app/Support/TenantAuth.php +++ b/app/Support/TenantAuth.php @@ -24,7 +24,7 @@ class TenantAuth } $user = $request->user(); - if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin'], true)) { + if ($user && in_array($user->role, ['tenant_admin', 'admin', 'super_admin', 'member'], true)) { if ($user->role !== 'super_admin' || (int) $user->tenant_id === (int) $tenantId) { return $user; } @@ -32,7 +32,7 @@ class TenantAuth $user = User::query() ->where('tenant_id', $tenantId) - ->whereIn('role', ['tenant_admin', 'admin']) + ->whereIn('role', ['tenant_admin', 'admin', 'member']) ->orderByDesc('email_verified_at') ->orderBy('id') ->first(); diff --git a/bootstrap/app.php b/bootstrap/app.php index c0e9566..27d92cc 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -2,6 +2,7 @@ use App\Http\Middleware\CreditCheckMiddleware; use App\Http\Middleware\EnsureTenantAdminToken; +use App\Http\Middleware\EnsureTenantCollaboratorToken; use App\Http\Middleware\HandleAppearance; use App\Http\Middleware\HandleInertiaRequests; use App\Http\Middleware\SetLocaleFromUser; @@ -33,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__)) 'superadmin.auth' => \App\Http\Middleware\SuperAdminAuth::class, 'credit.check' => CreditCheckMiddleware::class, 'tenant.admin' => EnsureTenantAdminToken::class, + 'tenant.collaborator' => EnsureTenantCollaboratorToken::class, ]); $middleware->encryptCookies(except: ['appearance', 'sidebar_state']); diff --git a/database/factories/EventMemberFactory.php b/database/factories/EventMemberFactory.php new file mode 100644 index 0000000..c79220e --- /dev/null +++ b/database/factories/EventMemberFactory.php @@ -0,0 +1,77 @@ + + */ +class EventMemberFactory extends Factory +{ + protected $model = EventMember::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'tenant_id' => Tenant::factory(), + 'event_id' => Event::factory(), + 'user_id' => null, + 'invited_by' => null, + 'name' => $this->faker->name(), + 'email' => $this->faker->unique()->safeEmail(), + 'role' => 'member', + 'status' => 'invited', + 'invite_token' => Str::random(32), + 'invited_at' => now(), + 'joined_at' => null, + 'last_activity_at' => null, + 'permissions' => null, + ]; + } + + public function admin(): static + { + return $this->state(fn () => [ + 'role' => 'tenant_admin', + 'status' => 'active', + 'joined_at' => now(), + ]); + } + + public function withUser(): static + { + return $this->state(fn (array $attributes) => [ + 'user_id' => User::factory()->state([ + 'role' => $attributes['role'] ?? 'member', + ]), + 'email' => $attributes['email'] ?? $this->faker->unique()->safeEmail(), + ]); + } + + public function configure(): static + { + return $this->afterMaking(function (EventMember $member): void { + $event = $member->event ?? Event::find($member->event_id); + if ($event && $member->tenant_id !== $event->tenant_id) { + $member->tenant_id = $event->tenant_id; + } + })->afterCreating(function (EventMember $member): void { + $event = $member->event()->withoutGlobalScopes()->first(); + if ($event && $member->tenant_id !== $event->tenant_id) { + $member->tenant_id = $event->tenant_id; + $member->save(); + } + }); + } +} diff --git a/database/migrations/2025_11_09_205320_create_event_members_table.php b/database/migrations/2025_11_09_205320_create_event_members_table.php new file mode 100644 index 0000000..2302ec0 --- /dev/null +++ b/database/migrations/2025_11_09_205320_create_event_members_table.php @@ -0,0 +1,43 @@ +id(); + $table->foreignId('tenant_id')->constrained()->cascadeOnDelete(); + $table->foreignId('event_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained()->cascadeOnDelete(); + $table->foreignId('invited_by')->nullable()->constrained('users')->nullOnDelete(); + $table->string('name')->nullable(); + $table->string('email')->nullable(); + $table->string('role')->default('member'); + $table->string('status')->default('invited'); + $table->string('invite_token', 64)->nullable()->unique(); + $table->timestamp('invited_at')->nullable(); + $table->timestamp('joined_at')->nullable(); + $table->timestamp('last_activity_at')->nullable(); + $table->json('permissions')->nullable(); + $table->timestamps(); + + $table->unique(['event_id', 'email']); + $table->index(['tenant_id', 'role']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('event_members'); + } +}; diff --git a/resources/js/admin/api.ts b/resources/js/admin/api.ts index a41c1a2..2fcb0f7 100644 --- a/resources/js/admin/api.ts +++ b/resources/js/admin/api.ts @@ -331,10 +331,12 @@ export type EventMember = { id: number; name: string; email: string | null; - role: 'tenant_admin' | 'member' | 'guest' | string; + role: 'tenant_admin' | 'member'; status?: 'pending' | 'active' | 'invited' | string; joined_at?: string | null; avatar_url?: string | null; + permissions?: string[] | null; + user_id?: number | null; }; type EventListResponse = { data?: JsonValue[] }; @@ -815,6 +817,12 @@ function normalizeMember(member: JsonValue): EventMember { status: member.status ?? 'active', joined_at: member.joined_at ?? member.created_at ?? null, avatar_url: member.avatar_url ?? member.avatar ?? null, + permissions: Array.isArray(member.permissions) + ? (member.permissions as string[]) + : member.permissions + ? String(member.permissions).split(',').map((entry) => entry.trim()) + : null, + user_id: member.user_id ?? null, }; } @@ -1695,7 +1703,10 @@ export async function getEventMembers(eventIdentifier: number | string, page = 1 }; } -export async function inviteEventMember(eventIdentifier: number | string, payload: { email: string; role?: string; name?: string }): Promise { +export async function inviteEventMember( + eventIdentifier: number | string, + payload: { email: string; role: EventMember['role']; name?: string } +): Promise { const response = await authorizedFetch( `/api/v1/tenant/events/${encodeURIComponent(String(eventIdentifier))}/members`, { diff --git a/resources/js/admin/auth/context.tsx b/resources/js/admin/auth/context.tsx index 14ffeb5..c9ac324 100644 --- a/resources/js/admin/auth/context.tsx +++ b/resources/js/admin/auth/context.tsx @@ -15,16 +15,20 @@ export type AuthStatus = 'loading' | 'authenticated' | 'unauthenticated'; export interface TenantProfile { id: number; tenant_id: number; + role?: string | null; name?: string; slug?: string; email?: string | null; event_credits_balance?: number | null; + features?: Record; [key: string]: unknown; } interface AuthContextValue { status: AuthStatus; user: TenantProfile | null; + abilities: string[]; + hasAbility: (ability: string) => boolean; refreshProfile: () => Promise; logout: (options?: { redirect?: string }) => Promise; applyToken: (token: string, abilities: string[]) => Promise; @@ -80,6 +84,7 @@ async function exchangeSessionForToken(): Promise<{ token: string; abilities: st export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const [status, setStatus] = React.useState('loading'); const [user, setUser] = React.useState(null); + const [abilities, setAbilities] = React.useState([]); const queryClient = useQueryClient(); const profileQueryKey = React.useMemo(() => ['tenantProfile'], []); @@ -88,6 +93,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children invalidateTenantApiCache(); queryClient.removeQueries({ queryKey: profileQueryKey }); setUser(null); + setAbilities([]); setStatus('unauthenticated'); }, [profileQueryKey, queryClient]); @@ -127,6 +133,7 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children : data.user; setUser(composed ?? null); + setAbilities(Array.isArray(data?.abilities) ? data.abilities : []); setStatus('authenticated'); } catch (error) { console.error('[Auth] Failed to refresh profile', error); @@ -141,8 +148,9 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, [handleAuthFailure, profileQueryKey, queryClient]); - const applyToken = React.useCallback(async (token: string, abilities: string[]) => { - storePersonalAccessToken(token, abilities); + const applyToken = React.useCallback(async (token: string, tokenAbilities: string[]) => { + storePersonalAccessToken(token, tokenAbilities); + setAbilities(tokenAbilities); await refreshProfile(); }, [refreshProfile]); @@ -221,9 +229,11 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children } }, [handleAuthFailure]); + const hasAbility = React.useCallback((ability: string) => abilities.includes(ability), [abilities]); + const value = React.useMemo( - () => ({ status, user, refreshProfile, logout, applyToken }), - [status, user, refreshProfile, logout, applyToken] + () => ({ status, user, abilities, hasAbility, refreshProfile, logout, applyToken }), + [status, user, abilities, hasAbility, refreshProfile, logout, applyToken] ); return {children}; diff --git a/resources/js/admin/components/AdminLayout.tsx b/resources/js/admin/components/AdminLayout.tsx index 955fa56..3f37de8 100644 --- a/resources/js/admin/components/AdminLayout.tsx +++ b/resources/js/admin/components/AdminLayout.tsx @@ -18,6 +18,7 @@ import { NotificationCenter } from './NotificationCenter'; import { UserMenu } from './UserMenu'; import { useEventContext } from '../context/EventContext'; import { EventSwitcher, EventMenuBar } from './EventNav'; +import { useAuth } from '../auth/context'; type NavItem = { key: string; @@ -50,7 +51,7 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP const photosLabel = t('navigation.photos', { defaultValue: 'Fotos' }); const settingsLabel = t('navigation.settings'); - const navItems = React.useMemo(() => [ + const baseNavItems = React.useMemo(() => [ { key: 'dashboard', to: ADMIN_HOME_PATH, @@ -85,6 +86,19 @@ export function AdminLayout({ title, subtitle, actions, children }: AdminLayoutP }, ], [eventsLabel, eventsPath, photosPath, photosLabel, settingsLabel, singleEvent, events.length, t]); + const { user } = useAuth(); + const isMember = user?.role === 'member'; + + const navItems = React.useMemo( + () => baseNavItems.filter((item) => { + if (!isMember) { + return true; + } + return !['dashboard', 'settings'].includes(item.key); + }), + [baseNavItems, isMember], + ); + const prefetchers = React.useMemo(() => ({ [ADMIN_HOME_PATH]: () => Promise.all([ diff --git a/resources/js/admin/components/UserMenu.tsx b/resources/js/admin/components/UserMenu.tsx index 4718221..a53078a 100644 --- a/resources/js/admin/components/UserMenu.tsx +++ b/resources/js/admin/components/UserMenu.tsx @@ -31,6 +31,8 @@ export function UserMenu() { const [pendingLocale, setPendingLocale] = React.useState(null); const currentLocale = getCurrentLocale(); + const isMember = user?.role === 'member'; + const initials = React.useMemo(() => { if (user?.name) { return user.name @@ -94,14 +96,18 @@ export function UserMenu() { {t('navigation.profile', { defaultValue: 'Profil' })} - goTo(ADMIN_SETTINGS_PATH)}> - - {t('navigation.settings', { defaultValue: 'Einstellungen' })} - - goTo(ADMIN_BILLING_PATH)}> - - {t('navigation.billing', { defaultValue: 'Billing' })} - + {!isMember && ( + <> + goTo(ADMIN_SETTINGS_PATH)}> + + {t('navigation.settings', { defaultValue: 'Einstellungen' })} + + goTo(ADMIN_BILLING_PATH)}> + + {t('navigation.billing', { defaultValue: 'Billing' })} + + + )} diff --git a/resources/js/admin/pages/EventMembersPage.tsx b/resources/js/admin/pages/EventMembersPage.tsx index ea39a01..318dffc 100644 --- a/resources/js/admin/pages/EventMembersPage.tsx +++ b/resources/js/admin/pages/EventMembersPage.tsx @@ -26,7 +26,7 @@ import { ADMIN_EVENTS_PATH } from '../constants'; type InviteForm = { email: string; name: string; - role: string; + role: EventMember['role']; }; const emptyInvite: InviteForm = { @@ -68,7 +68,6 @@ export default function EventMembersPage() { () => ({ tenant_admin: t('management.members.roles.tenantAdmin', 'Tenant-Admin'), member: t('management.members.roles.member', 'Mitglied'), - guest: t('management.members.roles.guest', 'Gast'), }), [t] ); @@ -275,6 +274,12 @@ export default function EventMembersPage() { {t('management.members.sections.invite.title', 'Neues Mitglied einladen')} +

+ {t( + 'management.members.sections.invite.helper', + 'Mitglieder erhalten Zugriff auf Fotomoderation, Aufgaben und QR-Einladungen. Admins steuern zusätzlich Pakete, Abrechnung und Events.' + )} +

@@ -308,7 +313,6 @@ export default function EventMembersPage() { {roleLabels.tenant_admin} {roleLabels.member} - {roleLabels.guest}
diff --git a/resources/js/admin/pages/LoginPage.tsx b/resources/js/admin/pages/LoginPage.tsx index 8fd15be..a509461 100644 --- a/resources/js/admin/pages/LoginPage.tsx +++ b/resources/js/admin/pages/LoginPage.tsx @@ -10,7 +10,7 @@ import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { useAuth } from '../auth/context'; -import { ADMIN_DEFAULT_AFTER_LOGIN_PATH } from '../constants'; +import { ADMIN_DEFAULT_AFTER_LOGIN_PATH, ADMIN_EVENTS_PATH } from '../constants'; import { encodeReturnTo, resolveReturnTarget } from '../lib/returnTo'; import { useMutation } from '@tanstack/react-query'; @@ -48,7 +48,7 @@ type LoginResponse = { } export default function LoginPage(): JSX.Element { - const { status, applyToken } = useAuth(); + const { status, applyToken, abilities } = useAuth(); const { t } = useTranslation('auth'); const location = useLocation(); const navigate = useNavigate(); @@ -56,7 +56,15 @@ export default function LoginPage(): JSX.Element { const searchParams = React.useMemo(() => new URLSearchParams(location.search), [location.search]); const rawReturnTo = searchParams.get('return_to'); - const fallbackTarget = ADMIN_DEFAULT_AFTER_LOGIN_PATH; + const computeDefaultAfterLogin = React.useCallback( + (abilityList?: string[]) => { + const source = abilityList ?? abilities; + return source.includes('tenant-admin') ? ADMIN_DEFAULT_AFTER_LOGIN_PATH : ADMIN_EVENTS_PATH; + }, + [abilities], + ); + + const fallbackTarget = computeDefaultAfterLogin(); const { finalTarget } = React.useMemo( () => resolveReturnTarget(rawReturnTo, fallbackTarget), [rawReturnTo, fallbackTarget] @@ -82,7 +90,9 @@ export default function LoginPage(): JSX.Element { onSuccess: async (data) => { setError(null); await applyToken(data.token, data.abilities ?? []); - navigate(finalTarget, { replace: true }); + const postLoginFallback = computeDefaultAfterLogin(data.abilities ?? []); + const { finalTarget: successTarget } = resolveReturnTarget(rawReturnTo, postLoginFallback); + navigate(successTarget, { replace: true }); }, }); diff --git a/resources/js/admin/router.tsx b/resources/js/admin/router.tsx index 58ddeae..0fae719 100644 --- a/resources/js/admin/router.tsx +++ b/resources/js/admin/router.tsx @@ -4,6 +4,7 @@ import { useAuth } from './auth/context'; import { ADMIN_BASE_PATH, ADMIN_DEFAULT_AFTER_LOGIN_PATH, + ADMIN_EVENTS_PATH, ADMIN_HOME_PATH, ADMIN_LOGIN_PATH, ADMIN_LOGIN_START_PATH, @@ -56,7 +57,7 @@ function RequireAuth() { } function LandingGate() { - const { status } = useAuth(); + const { status, user } = useAuth(); if (status === 'loading') { return ( @@ -67,12 +68,23 @@ function LandingGate() { } if (status === 'authenticated') { - return ; + const target = user?.role === 'member' ? ADMIN_EVENTS_PATH : ADMIN_DEFAULT_AFTER_LOGIN_PATH; + return ; } return ; } +function RequireAdminAccess({ children }: { children: React.ReactNode }) { + const { user } = useAuth(); + + if (user?.role === 'member') { + return ; + } + + return <>{children}; +} + export const router = createBrowserRouter([ { path: ADMIN_BASE_PATH, @@ -86,13 +98,13 @@ export const router = createBrowserRouter([ { element: , children: [ - { path: 'dashboard', element: }, + { path: 'dashboard', element: }, { path: 'events', element: }, - { path: 'events/new', element: }, + { path: 'events/new', element: }, { path: 'events/:slug', element: }, - { path: 'events/:slug/edit', element: }, + { path: 'events/:slug/edit', element: }, { path: 'events/:slug/photos', element: }, - { path: 'events/:slug/members', element: }, + { path: 'events/:slug/members', element: }, { path: 'events/:slug/tasks', element: }, { path: 'events/:slug/invites', element: }, { path: 'events/:slug/toolkit', element: }, @@ -100,14 +112,14 @@ export const router = createBrowserRouter([ { path: 'tasks', element: }, { path: 'task-collections', element: }, { path: 'emotions', element: }, - { path: 'billing', element: }, - { path: 'settings', element: }, + { path: 'billing', element: }, + { path: 'settings', element: }, { path: 'faq', element: }, - { path: 'settings/profile', element: }, - { path: 'welcome', element: }, - { path: 'welcome/packages', element: }, - { path: 'welcome/summary', element: }, - { path: 'welcome/event', element: }, + { path: 'settings/profile', element: }, + { path: 'welcome', element: }, + { path: 'welcome/packages', element: }, + { path: 'welcome/summary', element: }, + { path: 'welcome/event', element: }, ], }, ], diff --git a/routes/api.php b/routes/api.php index 339b8fd..bcb3685 100644 --- a/routes/api.php +++ b/routes/api.php @@ -8,6 +8,7 @@ use App\Http\Controllers\Api\Tenant\EmotionController; use App\Http\Controllers\Api\Tenant\EventController; use App\Http\Controllers\Api\Tenant\EventJoinTokenController; use App\Http\Controllers\Api\Tenant\EventJoinTokenLayoutController; +use App\Http\Controllers\Api\Tenant\EventMemberController; use App\Http\Controllers\Api\Tenant\EventTypeController; use App\Http\Controllers\Api\Tenant\NotificationLogController; use App\Http\Controllers\Api\Tenant\OnboardingController; @@ -80,29 +81,46 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->name('gallery.photos.asset'); }); - Route::middleware(['auth:sanctum', 'tenant.admin', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { - Route::get('profile', [ProfileController::class, 'show'])->name('tenant.profile.show'); - Route::put('profile', [ProfileController::class, 'update'])->name('tenant.profile.update'); - Route::get('onboarding', [OnboardingController::class, 'show'])->name('tenant.onboarding.show'); - Route::post('onboarding', [OnboardingController::class, 'store'])->name('tenant.onboarding.store'); - Route::get('me', [TenantAdminTokenController::class, 'legacyTenantMe'])->name('tenant.me'); - Route::get('dashboard', DashboardController::class)->name('tenant.dashboard'); + Route::middleware(['auth:sanctum', 'tenant.collaborator', 'tenant.isolation', 'throttle:tenant-api'])->prefix('tenant')->group(function () { + Route::get('profile', [ProfileController::class, 'show']) + ->middleware('tenant.admin') + ->name('tenant.profile.show'); + Route::put('profile', [ProfileController::class, 'update']) + ->middleware('tenant.admin') + ->name('tenant.profile.update'); + Route::get('onboarding', [OnboardingController::class, 'show']) + ->middleware('tenant.admin') + ->name('tenant.onboarding.show'); + Route::post('onboarding', [OnboardingController::class, 'store']) + ->middleware('tenant.admin') + ->name('tenant.onboarding.store'); + Route::get('me', [TenantAdminTokenController::class, 'legacyTenantMe']) + ->name('tenant.me'); + Route::get('dashboard', DashboardController::class) + ->middleware('tenant.admin') + ->name('tenant.dashboard'); Route::get('event-types', EventTypeController::class)->name('tenant.event-types.index'); - Route::apiResource('events', EventController::class) - ->only(['index', 'show', 'destroy']) - ->parameters(['events' => 'event:slug']); + Route::get('events', [EventController::class, 'index']) + ->name('tenant.events.index'); + Route::get('events/{event:slug}', [EventController::class, 'show']) + ->name('tenant.events.show'); + Route::delete('events/{event:slug}', [EventController::class, 'destroy']) + ->middleware('tenant.admin') + ->name('tenant.events.destroy'); - Route::middleware(['package.check', 'credit.check'])->group(function () { + Route::middleware(['package.check', 'credit.check', 'tenant.admin'])->group(function () { Route::post('events', [EventController::class, 'store'])->name('tenant.events.store'); Route::match(['put', 'patch'], 'events/{event:slug}', [EventController::class, 'update'])->name('tenant.events.update'); }); Route::prefix('events/{event:slug}')->scopeBindings()->group(function () { - Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats'); - Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle'); - Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites'); - Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit'); + Route::middleware('tenant.admin')->group(function () { + Route::get('stats', [EventController::class, 'stats'])->name('tenant.events.stats'); + Route::post('toggle', [EventController::class, 'toggle'])->name('tenant.events.toggle'); + Route::post('invites', [EventController::class, 'createInvite'])->name('tenant.events.invites'); + Route::get('toolkit', [EventController::class, 'toolkit'])->name('tenant.events.toolkit'); + }); Route::prefix('join-tokens')->group(function () { Route::get('/', [EventJoinTokenController::class, 'index'])->name('tenant.events.join-tokens.index'); @@ -122,21 +140,37 @@ Route::prefix('v1')->name('api.v1.')->group(function () { ->name('tenant.events.join-tokens.destroy'); }); - Route::get('photos', [PhotoController::class, 'index'])->name('tenant.events.photos.index'); - Route::post('photos', [PhotoController::class, 'store'])->name('tenant.events.photos.store'); - Route::get('photos/{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show'); - Route::patch('photos/{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update'); - Route::delete('photos/{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy'); - Route::post('photos/{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature'); - Route::post('photos/{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature'); - Route::post('photos/bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve'); - Route::post('photos/bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject'); - Route::get('photos/moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation'); - Route::get('photos/stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats'); + Route::prefix('photos')->group(function () { + Route::get('/', [PhotoController::class, 'index'])->name('tenant.events.photos.index'); + Route::post('/', [PhotoController::class, 'store'])->name('tenant.events.photos.store'); + Route::get('{photo}', [PhotoController::class, 'show'])->name('tenant.events.photos.show'); + Route::patch('{photo}', [PhotoController::class, 'update'])->name('tenant.events.photos.update'); + Route::delete('{photo}', [PhotoController::class, 'destroy'])->name('tenant.events.photos.destroy'); + Route::post('{photo}/feature', [PhotoController::class, 'feature'])->name('tenant.events.photos.feature'); + Route::post('{photo}/unfeature', [PhotoController::class, 'unfeature'])->name('tenant.events.photos.unfeature'); + Route::post('bulk-approve', [PhotoController::class, 'bulkApprove'])->name('tenant.events.photos.bulk-approve'); + Route::post('bulk-reject', [PhotoController::class, 'bulkReject'])->name('tenant.events.photos.bulk-reject'); + Route::get('moderation', [PhotoController::class, 'forModeration'])->name('tenant.events.photos.for-moderation'); + Route::get('stats', [PhotoController::class, 'stats'])->name('tenant.events.photos.stats'); + }); + + Route::get('members', [EventMemberController::class, 'index']) + ->middleware('tenant.admin') + ->name('tenant.events.members.index'); + Route::post('members', [EventMemberController::class, 'store']) + ->middleware('tenant.admin') + ->name('tenant.events.members.store'); + Route::delete('members/{member}', [EventMemberController::class, 'destroy']) + ->middleware('tenant.admin') + ->whereNumber('member') + ->name('tenant.events.members.destroy'); }); - Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus'])->name('tenant.events.bulk-status'); - Route::get('events/search', [EventController::class, 'search'])->name('tenant.events.search'); + Route::post('events/bulk-status', [EventController::class, 'bulkUpdateStatus']) + ->middleware('tenant.admin') + ->name('tenant.events.bulk-status'); + Route::get('events/search', [EventController::class, 'search']) + ->name('tenant.events.search'); Route::apiResource('tasks', TaskController::class); Route::post('tasks/{task}/assign-event/{event}', [TaskController::class, 'assignToEvent']) @@ -155,11 +189,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('task-collections/{collection}/activate', [TaskCollectionController::class, 'activate']) ->name('tenant.task-collections.activate'); - Route::get('emotions', [EmotionController::class, 'index'])->name('tenant.emotions.index'); - Route::post('emotions', [EmotionController::class, 'store'])->name('tenant.emotions.store'); - Route::patch('emotions/{emotion}', [EmotionController::class, 'update'])->name('tenant.emotions.update'); + Route::middleware('tenant.admin')->group(function () { + Route::get('emotions', [EmotionController::class, 'index'])->name('tenant.emotions.index'); + Route::post('emotions', [EmotionController::class, 'store'])->name('tenant.emotions.store'); + Route::patch('emotions/{emotion}', [EmotionController::class, 'update'])->name('tenant.emotions.update'); + }); - Route::prefix('settings')->group(function () { + Route::prefix('settings')->middleware('tenant.admin')->group(function () { Route::get('/', [SettingsController::class, 'index']) ->name('tenant.settings.index'); Route::post('/', [SettingsController::class, 'update']) @@ -175,9 +211,10 @@ Route::prefix('v1')->name('api.v1.')->group(function () { }); Route::get('notifications/logs', [NotificationLogController::class, 'index']) + ->middleware('tenant.admin') ->name('tenant.notifications.logs.index'); - Route::prefix('credits')->group(function () { + Route::prefix('credits')->middleware('tenant.admin')->group(function () { Route::get('balance', [CreditController::class, 'balance'])->name('tenant.credits.balance'); Route::get('ledger', [CreditController::class, 'ledger'])->name('tenant.credits.ledger'); Route::get('history', [CreditController::class, 'history'])->name('tenant.credits.history'); @@ -185,7 +222,7 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('sync', [CreditController::class, 'sync'])->name('tenant.credits.sync'); }); - Route::prefix('packages')->group(function () { + Route::prefix('packages')->middleware('tenant.admin')->group(function () { Route::get('/', [PackageController::class, 'index'])->name('packages.index'); Route::post('/purchase', [PackageController::class, 'purchase'])->name('packages.purchase'); Route::post('/payment-intent', [PackageController::class, 'createPaymentIntent'])->name('packages.payment-intent'); @@ -194,11 +231,12 @@ Route::prefix('v1')->name('api.v1.')->group(function () { Route::post('/paddle-checkout', [PackageController::class, 'createPaddleCheckout'])->name('packages.paddle-checkout'); }); - Route::prefix('tenant/packages')->group(function () { + Route::prefix('tenant/packages')->middleware('tenant.admin')->group(function () { Route::get('/', [TenantPackageController::class, 'index'])->name('tenant.packages.index'); }); Route::get('tenant/billing/transactions', [TenantBillingController::class, 'transactions']) + ->middleware('tenant.admin') ->name('tenant.billing.transactions'); Route::post('feedback', [TenantFeedbackController::class, 'store']) diff --git a/tests/Feature/Tenant/EventMemberControllerTest.php b/tests/Feature/Tenant/EventMemberControllerTest.php new file mode 100644 index 0000000..743f4bf --- /dev/null +++ b/tests/Feature/Tenant/EventMemberControllerTest.php @@ -0,0 +1,81 @@ +for($this->tenant)->create([ + 'slug' => 'collab-event', + ]); + + $payload = [ + 'email' => 'new.member@example.com', + 'name' => 'New Member', + 'role' => 'member', + ]; + + $response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/members", $payload); + + $response->assertCreated(); + $response->assertJsonPath('data.email', 'new.member@example.com'); + $response->assertJsonPath('data.role', 'member'); + + $this->assertDatabaseHas(EventMember::class, [ + 'event_id' => $event->id, + 'email' => 'new.member@example.com', + 'role' => 'member', + ]); + + $this->assertDatabaseHas(User::class, [ + 'email' => 'new.member@example.com', + 'tenant_id' => $this->tenant->id, + 'role' => 'member', + ]); + } + + public function test_member_token_can_access_photo_moderation_routes(): void + { + $event = Event::factory()->for($this->tenant)->create([ + 'slug' => 'moderate-event', + ]); + + $memberUser = User::factory()->create([ + 'email' => 'member@example.com', + 'tenant_id' => $this->tenant->id, + 'role' => 'member', + 'password' => Hash::make('secret123'), + ]); + + EventMember::factory()->create([ + 'tenant_id' => $this->tenant->id, + 'event_id' => $event->id, + 'user_id' => $memberUser->id, + 'email' => $memberUser->email, + 'role' => 'member', + 'status' => 'active', + 'permissions' => ['photos:moderate'], + ]); + + $login = $this->postJson('/api/v1/tenant-auth/login', [ + 'login' => $memberUser->email, + 'password' => 'secret123', + ]); + + $login->assertOk(); + $token = $login->json('token'); + + $response = $this->withHeaders([ + 'Authorization' => 'Bearer '.$token, + ])->getJson("/api/v1/tenant/events/{$event->slug}/photos"); + + $response->assertOk(); + $response->assertJsonStructure(['data']); + } +}