feat: platform admin backend — controllers, services, routes, tests
Add cross-organisation admin API endpoints behind role:super_admin middleware: - AdminOrganisationController: CRUD with search, filter, billing_status management - AdminUserController: user management with role assignment across orgs - AdminStatsController: platform-wide aggregate statistics - AdminActivityLogController: filterable activity log viewer - AdminImpersonationController + ImpersonationService: user impersonation with token-based session management and activity logging - BillingStatus enum, form requests, API resources, 23 feature tests Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
23
api/app/Enums/BillingStatus.php
Normal file
23
api/app/Enums/BillingStatus.php
Normal file
@@ -0,0 +1,23 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Enums;
|
||||
|
||||
enum BillingStatus: string
|
||||
{
|
||||
case TRIAL = 'trial';
|
||||
case ACTIVE = 'active';
|
||||
case SUSPENDED = 'suspended';
|
||||
case CANCELLED = 'cancelled';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::TRIAL => 'Trial',
|
||||
self::ACTIVE => 'Active',
|
||||
self::SUSPENDED => 'Suspended',
|
||||
self::CANCELLED => 'Cancelled',
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
|
||||
final class AdminActivityLogController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
$query = Activity::query()->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(),
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Resources\Admin\AdminUserResource;
|
||||
use App\Models\User;
|
||||
use App\Services\ImpersonationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
final class AdminImpersonationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ImpersonationService $impersonationService,
|
||||
) {}
|
||||
|
||||
public function start(User $user): JsonResponse
|
||||
{
|
||||
/** @var User $admin */
|
||||
$admin = auth()->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')),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\AdminUpdateOrganisationRequest;
|
||||
use App\Http\Resources\Admin\AdminOrganisationResource;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
final class AdminOrganisationController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
$query = Organisation::withoutGlobalScopes()
|
||||
->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
|
||||
final class AdminStatsController extends Controller
|
||||
{
|
||||
public function index(): JsonResponse
|
||||
{
|
||||
return response()->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(),
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Controllers\Api\V1\Admin;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Http\Requests\Admin\AdminUpdateUserRequest;
|
||||
use App\Http\Resources\Admin\AdminUserResource;
|
||||
use App\Models\User;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Resources\Json\AnonymousResourceCollection;
|
||||
|
||||
final class AdminUserController extends Controller
|
||||
{
|
||||
public function index(): AnonymousResourceCollection
|
||||
{
|
||||
$query = User::with('organisations');
|
||||
|
||||
if ($search = request('search')) {
|
||||
$query->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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use App\Enums\BillingStatus;
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
use Illuminate\Validation\Rules\Enum;
|
||||
|
||||
final class AdminUpdateOrganisationRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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'],
|
||||
];
|
||||
}
|
||||
}
|
||||
33
api/app/Http/Requests/Admin/AdminUpdateUserRequest.php
Normal file
33
api/app/Http/Requests/Admin/AdminUpdateUserRequest.php
Normal file
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Requests\Admin;
|
||||
|
||||
use Illuminate\Foundation\Http\FormRequest;
|
||||
use Illuminate\Validation\Rule;
|
||||
|
||||
final class AdminUpdateUserRequest extends FormRequest
|
||||
{
|
||||
public function authorize(): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
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'])],
|
||||
];
|
||||
}
|
||||
}
|
||||
32
api/app/Http/Resources/Admin/AdminOrganisationResource.php
Normal file
32
api/app/Http/Resources/Admin/AdminOrganisationResource.php
Normal file
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Admin;
|
||||
|
||||
use App\Enums\BillingStatus;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class AdminOrganisationResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
$billingStatus = BillingStatus::tryFrom($this->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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
37
api/app/Http/Resources/Admin/AdminUserResource.php
Normal file
37
api/app/Http/Resources/Admin/AdminUserResource.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Http\Resources\Admin;
|
||||
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Resources\Json\JsonResource;
|
||||
|
||||
final class AdminUserResource extends JsonResource
|
||||
{
|
||||
public function toArray(Request $request): array
|
||||
{
|
||||
return [
|
||||
'id' => $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,
|
||||
])
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
92
api/app/Services/ImpersonationService.php
Normal file
92
api/app/Services/ImpersonationService.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
final class ImpersonationService
|
||||
{
|
||||
/**
|
||||
* Start impersonating a target user.
|
||||
*
|
||||
* @return array{token: string, user: User, admin_id: string}
|
||||
*/
|
||||
public function start(User $admin, User $targetUser): array
|
||||
{
|
||||
if (! $admin->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;
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
124
api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php
Normal file
124
api/tests/Feature/Api/V1/Admin/AdminImpersonationTest.php
Normal file
@@ -0,0 +1,124 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1\Admin;
|
||||
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Spatie\Activitylog\Models\Activity;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminImpersonationTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $superAdmin;
|
||||
private User $targetUser;
|
||||
private User $otherSuperAdmin;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1\Admin;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\Person;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminOrganisationControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $superAdmin;
|
||||
private User $orgAdmin;
|
||||
private Organisation $organisation;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
66
api/tests/Feature/Api/V1/Admin/AdminStatsControllerTest.php
Normal file
66
api/tests/Feature/Api/V1/Admin/AdminStatsControllerTest.php
Normal file
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1\Admin;
|
||||
|
||||
use App\Models\Event;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminStatsControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $superAdmin;
|
||||
private User $regularUser;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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();
|
||||
}
|
||||
}
|
||||
151
api/tests/Feature/Api/V1/Admin/AdminUserControllerTest.php
Normal file
151
api/tests/Feature/Api/V1/Admin/AdminUserControllerTest.php
Normal file
@@ -0,0 +1,151 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tests\Feature\Api\V1\Admin;
|
||||
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Database\Seeders\RoleSeeder;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\TestCase;
|
||||
|
||||
class AdminUserControllerTest extends TestCase
|
||||
{
|
||||
use RefreshDatabase;
|
||||
|
||||
private User $superAdmin;
|
||||
private User $regularUser;
|
||||
private Organisation $organisation;
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
parent::setUp();
|
||||
$this->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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
196
dev-docs/API.md
196
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`)
|
||||
|
||||
Reference in New Issue
Block a user