diff --git a/api/app/Enums/BillingStatus.php b/api/app/Enums/BillingStatus.php new file mode 100644 index 00000000..16873977 --- /dev/null +++ b/api/app/Enums/BillingStatus.php @@ -0,0 +1,23 @@ + 'Trial', + self::ACTIVE => 'Active', + self::SUSPENDED => 'Suspended', + self::CANCELLED => 'Cancelled', + }; + } +} diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminActivityLogController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminActivityLogController.php new file mode 100644 index 00000000..24edc35f --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminActivityLogController.php @@ -0,0 +1,63 @@ +with('causer')->latest(); + + if ($causerId = request('causer_id')) { + $query->where('causer_id', $causerId); + } + + if ($subjectType = request('subject_type')) { + $query->where('subject_type', $subjectType); + } + + if ($logName = request('log_name')) { + $query->where('log_name', $logName); + } + + if ($from = request('from')) { + $query->where('created_at', '>=', $from); + } + + if ($to = request('to')) { + $query->where('created_at', '<=', $to); + } + + $activities = $query->paginate(25); + + return response()->json([ + 'data' => $activities->map(fn (Activity $activity) => [ + 'id' => $activity->id, + 'log_name' => $activity->log_name, + 'description' => $activity->description, + 'event' => $activity->event, + 'causer' => $activity->causer ? [ + 'id' => $activity->causer->id, + 'name' => $activity->causer->full_name ?? $activity->causer->name ?? null, + 'email' => $activity->causer->email ?? null, + ] : null, + 'subject_type' => $activity->subject_type, + 'subject_id' => $activity->subject_id, + 'properties' => $activity->properties, + 'created_at' => $activity->created_at->toIso8601String(), + ]), + 'meta' => [ + 'current_page' => $activities->currentPage(), + 'last_page' => $activities->lastPage(), + 'per_page' => $activities->perPage(), + 'total' => $activities->total(), + ], + ]); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php new file mode 100644 index 00000000..e09a268f --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminImpersonationController.php @@ -0,0 +1,42 @@ +user(); + $result = $this->impersonationService->start($admin, $user); + + return $this->success([ + 'token' => $result['token'], + 'user' => new AdminUserResource($result['user']->load('organisations')), + 'admin_id' => $result['admin_id'], + ]); + } + + public function stop(): JsonResponse + { + /** @var User $currentUser */ + $currentUser = auth()->user(); + $admin = $this->impersonationService->stop($currentUser); + + return $this->success([ + 'user' => new AdminUserResource($admin->load('organisations')), + ]); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminOrganisationController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminOrganisationController.php new file mode 100644 index 00000000..ecd41d7b --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminOrganisationController.php @@ -0,0 +1,94 @@ +withCount(['events', 'users']); + + if ($search = request('search')) { + $query->where(function ($q) use ($search) { + $q->where('name', 'like', "%{$search}%") + ->orWhere('slug', 'like', "%{$search}%"); + }); + } + + if ($billingStatus = request('billing_status')) { + $query->where('billing_status', $billingStatus); + } + + $sortBy = request('sort', 'name'); + $sortDirection = request('direction', 'asc'); + $query->orderBy( + in_array($sortBy, ['name', 'created_at']) ? $sortBy : 'name', + $sortDirection === 'desc' ? 'desc' : 'asc', + ); + + return AdminOrganisationResource::collection($query->paginate()); + } + + public function show(string $organisationId): JsonResponse + { + $organisation = Organisation::withoutGlobalScopes() + ->withCount(['events', 'users']) + ->findOrFail($organisationId); + + $organisation->total_persons = Person::withoutGlobalScopes() + ->whereIn('event_id', $organisation->events()->select('id')) + ->count(); + + return $this->success(new AdminOrganisationResource($organisation)); + } + + public function store(): void + { + // Organisations are created via the regular endpoint + abort(405); + } + + public function update(AdminUpdateOrganisationRequest $request, string $organisationId): JsonResponse + { + $organisation = Organisation::withoutGlobalScopes()->findOrFail($organisationId); + + $organisation->update($request->validated()); + + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($organisation) + ->event('admin.organisation.updated') + ->withProperties($request->validated()) + ->log('Updated organisation ' . $organisation->name); + + $organisation->loadCount(['events', 'users']); + + return $this->success(new AdminOrganisationResource($organisation)); + } + + public function destroy(string $organisationId): JsonResponse + { + $organisation = Organisation::withoutGlobalScopes()->findOrFail($organisationId); + + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($organisation) + ->event('admin.organisation.deleted') + ->log('Deleted organisation ' . $organisation->name); + + $organisation->delete(); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminStatsController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminStatsController.php new file mode 100644 index 00000000..e2d2149e --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminStatsController.php @@ -0,0 +1,44 @@ +json([ + 'data' => [ + 'organisations' => [ + 'total' => Organisation::withoutGlobalScopes()->count(), + 'by_billing_status' => Organisation::withoutGlobalScopes() + ->selectRaw('billing_status, COUNT(*) as count') + ->groupBy('billing_status') + ->pluck('count', 'billing_status'), + ], + 'events' => [ + 'total' => Event::withoutGlobalScopes()->count(), + 'by_status' => Event::withoutGlobalScopes() + ->selectRaw('status, COUNT(*) as count') + ->groupBy('status') + ->pluck('count', 'status'), + ], + 'users' => [ + 'total' => User::count(), + 'verified' => User::whereNotNull('email_verified_at')->count(), + ], + 'persons' => [ + 'total' => Person::withoutGlobalScopes()->count(), + ], + ], + ]); + } +} diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminUserController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminUserController.php new file mode 100644 index 00000000..6349fb34 --- /dev/null +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminUserController.php @@ -0,0 +1,90 @@ +where(function ($q) use ($search) { + $q->where('first_name', 'like', "%{$search}%") + ->orWhere('last_name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + if ($organisationId = request('organisation_id')) { + $query->whereHas('organisations', fn ($q) => $q->where('organisations.id', $organisationId)); + } + + if ($role = request('role')) { + $query->role($role); + } + + $query->orderBy('first_name')->orderBy('last_name'); + + return AdminUserResource::collection($query->paginate()); + } + + public function show(User $user): JsonResponse + { + $user->load('organisations'); + + return $this->success(new AdminUserResource($user)); + } + + public function update(AdminUpdateUserRequest $request, User $user): JsonResponse + { + $validated = $request->validated(); + $roles = $validated['roles'] ?? null; + unset($validated['roles']); + + if (! empty($validated)) { + $user->update($validated); + } + + if ($roles !== null) { + // Sync only platform-level roles, preserving org/event roles + $platformRoles = ['super_admin', 'support_agent']; + $currentRoles = $user->getRoleNames()->filter(fn ($r) => ! in_array($r, $platformRoles))->all(); + $user->syncRoles(array_merge($currentRoles, $roles)); + } + + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($user) + ->event('admin.user.updated') + ->withProperties($request->validated()) + ->log('Updated user ' . $user->full_name); + + $user->load('organisations'); + + return $this->success(new AdminUserResource($user)); + } + + public function destroy(User $user): JsonResponse + { + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($user) + ->event('admin.user.deleted') + ->log('Deleted user ' . $user->full_name); + + $user->tokens()->delete(); + $user->delete(); + + return response()->json(null, 204); + } +} diff --git a/api/app/Http/Requests/Admin/AdminUpdateOrganisationRequest.php b/api/app/Http/Requests/Admin/AdminUpdateOrganisationRequest.php new file mode 100644 index 00000000..6faeccaf --- /dev/null +++ b/api/app/Http/Requests/Admin/AdminUpdateOrganisationRequest.php @@ -0,0 +1,32 @@ + */ + public function rules(): array + { + return [ + 'name' => ['sometimes', 'string', 'max:255'], + 'slug' => [ + 'sometimes', 'string', 'max:255', 'regex:/^[a-z0-9-]+$/', + Rule::unique('organisations', 'slug')->ignore($this->route('organisation')), + ], + 'billing_status' => ['sometimes', new Enum(BillingStatus::class)], + 'settings' => ['nullable', 'array'], + ]; + } +} diff --git a/api/app/Http/Requests/Admin/AdminUpdateUserRequest.php b/api/app/Http/Requests/Admin/AdminUpdateUserRequest.php new file mode 100644 index 00000000..365fcb11 --- /dev/null +++ b/api/app/Http/Requests/Admin/AdminUpdateUserRequest.php @@ -0,0 +1,33 @@ + */ + public function rules(): array + { + return [ + 'first_name' => ['sometimes', 'string', 'max:255'], + 'last_name' => ['sometimes', 'string', 'max:255'], + 'email' => [ + 'sometimes', 'string', 'email', 'max:255', + Rule::unique('users', 'email')->ignore($this->route('user')), + ], + 'timezone' => ['sometimes', 'string', 'timezone'], + 'locale' => ['sometimes', 'string', Rule::in(['nl', 'en'])], + 'roles' => ['nullable', 'array'], + 'roles.*' => ['string', Rule::in(['super_admin', 'support_agent'])], + ]; + } +} diff --git a/api/app/Http/Resources/Admin/AdminOrganisationResource.php b/api/app/Http/Resources/Admin/AdminOrganisationResource.php new file mode 100644 index 00000000..de34169a --- /dev/null +++ b/api/app/Http/Resources/Admin/AdminOrganisationResource.php @@ -0,0 +1,32 @@ +billing_status); + + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'billing_status' => $this->billing_status, + 'billing_status_label' => $billingStatus?->label(), + 'settings' => $this->settings, + 'events_count' => $this->whenCounted('events'), + 'users_count' => $this->whenCounted('users'), + 'total_persons' => $this->when(isset($this->total_persons), $this->total_persons), + 'created_at' => $this->created_at->toIso8601String(), + 'updated_at' => $this->updated_at->toIso8601String(), + 'deleted_at' => $this->deleted_at?->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/Admin/AdminUserResource.php b/api/app/Http/Resources/Admin/AdminUserResource.php new file mode 100644 index 00000000..8010c970 --- /dev/null +++ b/api/app/Http/Resources/Admin/AdminUserResource.php @@ -0,0 +1,37 @@ + $this->id, + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'full_name' => $this->full_name, + 'email' => $this->email, + 'avatar' => $this->avatar, + 'timezone' => $this->timezone, + 'locale' => $this->locale, + 'email_verified_at' => $this->email_verified_at?->toIso8601String(), + 'created_at' => $this->created_at->toIso8601String(), + 'roles' => $this->getRoleNames()->values()->all(), + 'is_super_admin' => $this->hasRole('super_admin'), + 'organisations' => $this->whenLoaded('organisations', fn () => + $this->organisations->map(fn ($org) => [ + 'id' => $org->id, + 'name' => $org->name, + 'slug' => $org->slug, + 'role' => $org->pivot->role, + ]) + ), + ]; + } +} diff --git a/api/app/Services/ImpersonationService.php b/api/app/Services/ImpersonationService.php new file mode 100644 index 00000000..523454f8 --- /dev/null +++ b/api/app/Services/ImpersonationService.php @@ -0,0 +1,92 @@ +hasRole('super_admin')) { + abort(403, 'Only super admins can impersonate users.'); + } + + if ($targetUser->hasRole('super_admin')) { + abort(403, 'Cannot impersonate another super admin.'); + } + + $tokenName = 'impersonation-by-' . $admin->id; + $newToken = $targetUser->createToken($tokenName); + + Cache::put( + "impersonation:{$newToken->accessToken->id}", + $admin->id, + now()->addHours(4), + ); + + activity('admin') + ->causedBy($admin) + ->performedOn($targetUser) + ->event('admin.impersonation.started') + ->withProperties([ + 'admin_id' => $admin->id, + 'target_user_id' => $targetUser->id, + ]) + ->log('Started impersonating user ' . $targetUser->full_name); + + return [ + 'token' => $newToken->plainTextToken, + 'user' => $targetUser, + 'admin_id' => $admin->id, + ]; + } + + /** + * Stop impersonation and return the original admin. + */ + public function stop(User $currentUser): User + { + $currentToken = $currentUser->currentAccessToken(); + + if (! $currentToken || ! str_starts_with($currentToken->name, 'impersonation-by-')) { + abort(400, 'No active impersonation session.'); + } + + $adminId = Cache::get("impersonation:{$currentToken->id}"); + + $admin = $adminId ? User::find($adminId) : null; + + if (! $admin) { + // Fallback: extract admin ID from token name + $admin = User::find(str_replace('impersonation-by-', '', $currentToken->name)); + } + + activity('admin') + ->causedBy($admin ?? $currentUser) + ->performedOn($currentUser) + ->event('admin.impersonation.stopped') + ->withProperties([ + 'admin_id' => $admin?->id, + 'impersonated_user_id' => $currentUser->id, + ]) + ->log('Stopped impersonating user ' . $currentUser->full_name); + + Cache::forget("impersonation:{$currentToken->id}"); + $currentToken->delete(); + + if (! $admin) { + abort(400, 'Could not resolve original admin session.'); + } + + return $admin; + } +} diff --git a/api/bootstrap/app.php b/api/bootstrap/app.php index 6421d902..a0807971 100644 --- a/api/bootstrap/app.php +++ b/api/bootstrap/app.php @@ -34,6 +34,7 @@ return Application::configure(basePath: dirname(__DIR__)) $middleware->alias([ 'portal.token' => \App\Http\Middleware\PortalTokenMiddleware::class, + 'role' => \Spatie\Permission\Middleware\RoleMiddleware::class, ]); }) ->withExceptions(function (Exceptions $exceptions): void { diff --git a/api/routes/api.php b/api/routes/api.php index edc7eaac..d2720bf1 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -36,6 +36,11 @@ use App\Http\Controllers\Api\V1\PasswordResetController; use App\Http\Controllers\Api\V1\PortalMeController; use App\Http\Controllers\Api\V1\Portal\PortalShiftController; use App\Http\Controllers\Api\V1\UserOrganisationTagController; +use App\Http\Controllers\Api\V1\Admin\AdminOrganisationController; +use App\Http\Controllers\Api\V1\Admin\AdminUserController; +use App\Http\Controllers\Api\V1\Admin\AdminStatsController; +use App\Http\Controllers\Api\V1\Admin\AdminActivityLogController; +use App\Http\Controllers\Api\V1\Admin\AdminImpersonationController; use App\Models\FestivalSection; use App\Models\Organisation; use Illuminate\Support\Facades\Gate; @@ -78,8 +83,33 @@ Route::post('public/check-email', CheckEmailController::class)->middleware('thro Route::post('events/{event}/volunteer-register', VolunteerRegistrationController::class)->middleware('throttle:5,1'); Route::post('portal/token-auth', [PortalTokenController::class, 'auth'])->middleware('throttle:10,1'); +// Platform Admin routes +Route::prefix('admin') + ->middleware(['auth:sanctum', 'role:super_admin']) + ->name('admin.') + ->group(function () { + // Organisations + Route::apiResource('organisations', AdminOrganisationController::class); + + // Users + Route::apiResource('users', AdminUserController::class) + ->except(['store']); + + // Platform statistics + Route::get('stats', [AdminStatsController::class, 'index']); + + // Activity log + Route::get('activity-log', [AdminActivityLogController::class, 'index']); + + // Impersonation (start) + Route::post('impersonate/{user}', [AdminImpersonationController::class, 'start']); + }); + // Protected routes Route::middleware('auth:sanctum')->group(function () { + // Impersonation (stop — accessible by impersonated user, not just super_admin) + Route::post('admin/stop-impersonation', [AdminImpersonationController::class, 'stop']); + // Auth Route::get('auth/me', MeController::class); Route::post('auth/logout', LogoutController::class); diff --git a/api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php b/api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php new file mode 100644 index 00000000..a22e341b --- /dev/null +++ b/api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php @@ -0,0 +1,124 @@ +seed(RoleSeeder::class); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->targetUser = User::factory()->create(); + + $this->otherSuperAdmin = User::factory()->create(); + $this->otherSuperAdmin->assignRole('super_admin'); + } + + // ─── Start ─────────────────────────────────────────────── + + public function test_start_creates_token_for_target_user(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); + + $response->assertOk(); + $response->assertJsonStructure([ + 'data' => ['token', 'user' => ['id', 'email'], 'admin_id'], + ]); + $response->assertJsonPath('data.user.id', $this->targetUser->id); + $response->assertJsonPath('data.admin_id', $this->superAdmin->id); + + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $this->targetUser->id, + 'name' => 'impersonation-by-' . $this->superAdmin->id, + ]); + } + + public function test_start_denied_for_non_super_admin(): void + { + Sanctum::actingAs($this->targetUser); + + $response = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); + + $response->assertForbidden(); + } + + public function test_start_denied_when_target_is_super_admin(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson("/api/v1/admin/impersonate/{$this->otherSuperAdmin->id}"); + + $response->assertForbidden(); + } + + // ─── Stop ──────────────────────────────────────────────── + + public function test_stop_deletes_impersonation_token(): void + { + // Start impersonation + Sanctum::actingAs($this->superAdmin); + $startResponse = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); + $token = $startResponse->json('data.token'); + + // Reset auth state so the Bearer token takes effect + $this->app['auth']->forgetGuards(); + + $response = $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/admin/stop-impersonation'); + + $response->assertOk(); + $response->assertJsonPath('data.user.id', $this->superAdmin->id); + + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $this->targetUser->id, + 'name' => 'impersonation-by-' . $this->superAdmin->id, + ]); + } + + // ─── Activity Log ──────────────────────────────────────── + + public function test_activity_log_records_start_and_stop(): void + { + Sanctum::actingAs($this->superAdmin); + $startResponse = $this->postJson("/api/v1/admin/impersonate/{$this->targetUser->id}"); + $token = $startResponse->json('data.token'); + + $this->assertDatabaseHas('activity_log', [ + 'event' => 'admin.impersonation.started', + 'causer_id' => $this->superAdmin->id, + 'subject_id' => $this->targetUser->id, + ]); + + // Reset auth state so the Bearer token takes effect + $this->app['auth']->forgetGuards(); + + $this->withHeader('Authorization', "Bearer {$token}") + ->postJson('/api/v1/admin/stop-impersonation'); + + $this->assertDatabaseHas('activity_log', [ + 'event' => 'admin.impersonation.stopped', + 'subject_id' => $this->targetUser->id, + ]); + } +} diff --git a/api/tests/Feature/Api/V1/Admin/AdminOrganisationControllerTest.php b/api/tests/Feature/Api/V1/Admin/AdminOrganisationControllerTest.php new file mode 100644 index 00000000..98acd013 --- /dev/null +++ b/api/tests/Feature/Api/V1/Admin/AdminOrganisationControllerTest.php @@ -0,0 +1,149 @@ +seed(RoleSeeder::class); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->orgAdmin = User::factory()->create(); + + $this->organisation = Organisation::factory()->create(['billing_status' => 'active']); + $this->organisation->users()->attach($this->orgAdmin, ['role' => 'org_admin']); + } + + // ─── Index ─────────────────────────────────────────────── + + public function test_index_returns_all_organisations_for_super_admin(): void + { + Organisation::factory()->count(3)->create(); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/admin/organisations'); + + $response->assertOk(); + $response->assertJsonStructure([ + 'data' => [['id', 'name', 'slug', 'billing_status', 'events_count', 'users_count']], + ]); + // 1 from setUp + 3 created = 4 + $this->assertCount(4, $response->json('data')); + } + + public function test_index_denied_for_org_admin(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->getJson('/api/v1/admin/organisations'); + + $response->assertForbidden(); + } + + public function test_index_denied_for_unauthenticated(): void + { + $response = $this->getJson('/api/v1/admin/organisations'); + + $response->assertUnauthorized(); + } + + public function test_search_by_name(): void + { + Organisation::factory()->create(['name' => 'Festival Corp']); + Organisation::factory()->create(['name' => 'Music Events BV']); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/admin/organisations?search=Festival'); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $response->assertJsonPath('data.0.name', 'Festival Corp'); + } + + public function test_filter_by_billing_status(): void + { + Organisation::factory()->create(['billing_status' => 'trial']); + Organisation::factory()->create(['billing_status' => 'suspended']); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/admin/organisations?billing_status=trial'); + + $response->assertOk(); + foreach ($response->json('data') as $org) { + $this->assertEquals('trial', $org['billing_status']); + } + } + + // ─── Show ──────────────────────────────────────────────── + + public function test_show_returns_organisation_with_counts(): void + { + $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson("/api/v1/admin/organisations/{$this->organisation->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.id', $this->organisation->id); + $response->assertJsonPath('data.events_count', 1); + $response->assertJsonPath('data.users_count', 1); + $response->assertJsonStructure([ + 'data' => ['id', 'name', 'slug', 'billing_status', 'billing_status_label', 'events_count', 'users_count', 'total_persons'], + ]); + } + + // ─── Update ────────────────────────────────────────────── + + public function test_update_changes_billing_status(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->putJson("/api/v1/admin/organisations/{$this->organisation->id}", [ + 'billing_status' => 'suspended', + ]); + + $response->assertOk(); + $response->assertJsonPath('data.billing_status', 'suspended'); + $this->assertDatabaseHas('organisations', [ + 'id' => $this->organisation->id, + 'billing_status' => 'suspended', + ]); + } + + // ─── Destroy ───────────────────────────────────────────── + + public function test_destroy_soft_deletes(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->deleteJson("/api/v1/admin/organisations/{$this->organisation->id}"); + + $response->assertNoContent(); + $this->assertSoftDeleted('organisations', ['id' => $this->organisation->id]); + } +} diff --git a/api/tests/Feature/Api/V1/Admin/AdminStatsControllerTest.php b/api/tests/Feature/Api/V1/Admin/AdminStatsControllerTest.php new file mode 100644 index 00000000..7d5d4c22 --- /dev/null +++ b/api/tests/Feature/Api/V1/Admin/AdminStatsControllerTest.php @@ -0,0 +1,66 @@ +seed(RoleSeeder::class); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->regularUser = User::factory()->create(); + } + + public function test_returns_aggregate_counts(): void + { + $org = Organisation::factory()->create(['billing_status' => 'active']); + Event::factory()->count(2)->create([ + 'organisation_id' => $org->id, + 'status' => 'draft', + ]); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/admin/stats'); + + $response->assertOk(); + $response->assertJsonStructure([ + 'data' => [ + 'organisations' => ['total', 'by_billing_status'], + 'events' => ['total', 'by_status'], + 'users' => ['total', 'verified'], + 'persons' => ['total'], + ], + ]); + $this->assertGreaterThanOrEqual(1, $response->json('data.organisations.total')); + $this->assertGreaterThanOrEqual(2, $response->json('data.events.total')); + } + + public function test_denied_for_non_super_admin(): void + { + Sanctum::actingAs($this->regularUser); + + $response = $this->getJson('/api/v1/admin/stats'); + + $response->assertForbidden(); + } +} diff --git a/api/tests/Feature/Api/V1/Admin/AdminUserControllerTest.php b/api/tests/Feature/Api/V1/Admin/AdminUserControllerTest.php new file mode 100644 index 00000000..1c141a7c --- /dev/null +++ b/api/tests/Feature/Api/V1/Admin/AdminUserControllerTest.php @@ -0,0 +1,151 @@ +seed(RoleSeeder::class); + + $this->superAdmin = User::factory()->create(); + $this->superAdmin->assignRole('super_admin'); + + $this->organisation = Organisation::factory()->create(); + + $this->regularUser = User::factory()->create(); + $this->organisation->users()->attach($this->regularUser, ['role' => 'org_admin']); + } + + // ─── Index ─────────────────────────────────────────────── + + public function test_index_returns_all_users_with_organisations(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/admin/users'); + + $response->assertOk(); + $response->assertJsonStructure([ + 'data' => [['id', 'first_name', 'last_name', 'full_name', 'email', 'is_super_admin', 'organisations']], + ]); + // superAdmin + regularUser = 2 + $this->assertCount(2, $response->json('data')); + } + + public function test_index_denied_for_non_super_admin(): void + { + Sanctum::actingAs($this->regularUser); + + $response = $this->getJson('/api/v1/admin/users'); + + $response->assertForbidden(); + } + + public function test_search_by_email(): void + { + $user = User::factory()->create(['email' => 'searchable@crewli.test']); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson('/api/v1/admin/users?search=searchable@crewli'); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $response->assertJsonPath('data.0.email', 'searchable@crewli.test'); + } + + public function test_filter_by_organisation_id(): void + { + $otherOrg = Organisation::factory()->create(); + $otherUser = User::factory()->create(); + $otherOrg->users()->attach($otherUser, ['role' => 'org_member']); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson("/api/v1/admin/users?organisation_id={$this->organisation->id}"); + + $response->assertOk(); + $this->assertCount(1, $response->json('data')); + $response->assertJsonPath('data.0.id', $this->regularUser->id); + } + + // ─── Show ──────────────────────────────────────────────── + + public function test_show_returns_user_with_org_memberships(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->getJson("/api/v1/admin/users/{$this->regularUser->id}"); + + $response->assertOk(); + $response->assertJsonPath('data.id', $this->regularUser->id); + $response->assertJsonPath('data.organisations.0.id', $this->organisation->id); + $response->assertJsonPath('data.organisations.0.role', 'org_admin'); + } + + // ─── Update ────────────────────────────────────────────── + + public function test_update_changes_name_and_email(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->putJson("/api/v1/admin/users/{$this->regularUser->id}", [ + 'first_name' => 'Updated', + 'last_name' => 'Name', + 'email' => 'updated@crewli.test', + ]); + + $response->assertOk(); + $response->assertJsonPath('data.first_name', 'Updated'); + $response->assertJsonPath('data.email', 'updated@crewli.test'); + } + + public function test_update_can_assign_super_admin_role(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->putJson("/api/v1/admin/users/{$this->regularUser->id}", [ + 'roles' => ['super_admin'], + ]); + + $response->assertOk(); + $this->assertTrue($this->regularUser->fresh()->hasRole('super_admin')); + } + + // ─── Destroy ───────────────────────────────────────────── + + public function test_destroy_soft_deletes_and_revokes_tokens(): void + { + $this->regularUser->createToken('test-token'); + $this->assertDatabaseHas('personal_access_tokens', [ + 'tokenable_id' => $this->regularUser->id, + ]); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->deleteJson("/api/v1/admin/users/{$this->regularUser->id}"); + + $response->assertNoContent(); + $this->assertSoftDeleted('users', ['id' => $this->regularUser->id]); + $this->assertDatabaseMissing('personal_access_tokens', [ + 'tokenable_id' => $this->regularUser->id, + ]); + } +} diff --git a/dev-docs/API.md b/dev-docs/API.md index 509e1150..b5ad6c0c 100644 --- a/dev-docs/API.md +++ b/dev-docs/API.md @@ -726,3 +726,199 @@ Additional filter parameters on `GET /organisations/{org}/events/{event}/persons - `?has_preference=true` — only persons who submitted section preferences _(Extend this contract per module as endpoints are implemented.)_ + +## Platform Admin + +All admin endpoints require `auth:sanctum` + `role:super_admin`. They bypass OrganisationScope and query across all organisations. + +Base path: `/api/v1/admin/` + +### Admin Organisations + +- `GET /admin/organisations` — list all organisations (paginated) +- `GET /admin/organisations/{organisation}` — show with counts and total persons +- `POST /admin/organisations` — not supported (405), use regular endpoint +- `PUT /admin/organisations/{organisation}` — update name, slug, billing_status, settings +- `DELETE /admin/organisations/{organisation}` — soft delete + +#### Query Parameters (index) + +- `search` — filter by name or slug (partial match) +- `billing_status` — filter by billing status (`trial`, `active`, `suspended`, `cancelled`) +- `sort` — sort field: `name` (default), `created_at` +- `direction` — sort direction: `asc` (default), `desc` + +#### AdminOrganisationResource + +```json +{ + "id": "01JXYZ...", + "name": "Festival Corp", + "slug": "festival-corp", + "billing_status": "active", + "billing_status_label": "Active", + "settings": {}, + "events_count": 5, + "users_count": 12, + "total_persons": 342, + "created_at": "2026-01-15T10:00:00+00:00", + "updated_at": "2026-04-10T12:00:00+00:00", + "deleted_at": null +} +``` + +#### Update Body + +```json +{ + "name": "Festival Corp", + "slug": "festival-corp", + "billing_status": "suspended", + "settings": {} +} +``` + +### Admin Users + +- `GET /admin/users` — list all users with organisation memberships (paginated) +- `GET /admin/users/{user}` — show with organisations and roles +- `PUT /admin/users/{user}` — update name, email, timezone, locale, platform roles +- `DELETE /admin/users/{user}` — soft delete + revoke all tokens + +#### Query Parameters (index) + +- `search` — filter by first_name, last_name, or email (partial match) +- `organisation_id` — filter by organisation membership +- `role` — filter by Spatie role name + +#### AdminUserResource + +```json +{ + "id": "01JXYZ...", + "first_name": "Jan", + "last_name": "de Vries", + "full_name": "Jan de Vries", + "email": "jan@example.nl", + "avatar": null, + "timezone": "Europe/Amsterdam", + "locale": "nl", + "email_verified_at": "2026-01-15T10:00:00+00:00", + "created_at": "2026-01-15T10:00:00+00:00", + "roles": ["super_admin"], + "is_super_admin": true, + "organisations": [ + { "id": "01JXYZ...", "name": "Festival Corp", "slug": "festival-corp", "role": "org_admin" } + ] +} +``` + +#### Update Body + +```json +{ + "first_name": "Jan", + "last_name": "de Vries", + "email": "jan@example.nl", + "timezone": "Europe/Amsterdam", + "locale": "nl", + "roles": ["super_admin"] +} +``` + +`roles` accepts platform-level roles only: `super_admin`, `support_agent`. Organisation/event roles are managed via the regular endpoints. + +### Admin Stats + +- `GET /admin/stats` — platform-wide aggregate counts + +#### Response + +```json +{ + "data": { + "organisations": { + "total": 15, + "by_billing_status": { "trial": 3, "active": 10, "suspended": 1, "cancelled": 1 } + }, + "events": { + "total": 42, + "by_status": { "draft": 10, "published": 8, "registration_open": 12, "showday": 5, "closed": 7 } + }, + "users": { + "total": 156, + "verified": 142 + }, + "persons": { + "total": 2340 + } + } +} +``` + +### Admin Activity Log + +- `GET /admin/activity-log` — paginated activity log (25 per page) + +#### Query Parameters + +- `causer_id` — filter by user who caused the action +- `subject_type` — filter by subject model type +- `log_name` — filter by log name (e.g. `admin`, `default`) +- `from` — filter from date (ISO 8601) +- `to` — filter to date (ISO 8601) + +#### Response + +```json +{ + "data": [ + { + "id": 1, + "log_name": "admin", + "description": "Updated organisation Festival Corp", + "event": "admin.organisation.updated", + "causer": { "id": "01JXYZ...", "name": "Super Admin", "email": "admin@crewli.app" }, + "subject_type": "App\\Models\\Organisation", + "subject_id": "01JXYZ...", + "properties": { "billing_status": "suspended" }, + "created_at": "2026-04-14T10:00:00+00:00" + } + ], + "meta": { "current_page": 1, "last_page": 1, "per_page": 25, "total": 1 } +} +``` + +### Admin Impersonation + +- `POST /admin/impersonate/{user}` — start impersonating a user (requires `role:super_admin`) +- `POST /admin/stop-impersonation` — stop impersonation (requires `auth:sanctum` only, callable by impersonated user) + +#### Start Response + +```json +{ + "data": { + "token": "1|abc123...", + "user": { "...AdminUserResource..." }, + "admin_id": "01JXYZ..." + } +} +``` + +#### Stop Response + +```json +{ + "data": { + "user": { "...AdminUserResource (original admin)..." } + } +} +``` + +#### Business Rules + +- Cannot impersonate another super_admin (403) +- Impersonation token has name `impersonation-by-{admin_id}` +- Admin ID is cached for 4 hours at key `impersonation:{token_id}` +- Activity log records both start (`admin.impersonation.started`) and stop (`admin.impersonation.stopped`)