diff --git a/api/app/Http/Controllers/Api/V1/Admin/AdminOrganisationController.php b/api/app/Http/Controllers/Api/V1/Admin/AdminOrganisationController.php index ecd41d7b..c7af0af9 100644 --- a/api/app/Http/Controllers/Api/V1/Admin/AdminOrganisationController.php +++ b/api/app/Http/Controllers/Api/V1/Admin/AdminOrganisationController.php @@ -5,10 +5,15 @@ declare(strict_types=1); namespace App\Http\Controllers\Api\V1\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\AdminInviteMemberRequest; +use App\Http\Requests\Admin\AdminUpdateMemberRoleRequest; use App\Http\Requests\Admin\AdminUpdateOrganisationRequest; use App\Http\Resources\Admin\AdminOrganisationResource; +use App\Http\Resources\Api\V1\MemberResource; use App\Models\Organisation; use App\Models\Person; +use App\Models\User; +use App\Models\UserInvitation; use Illuminate\Http\JsonResponse; use Illuminate\Http\Resources\Json\AnonymousResourceCollection; @@ -50,7 +55,12 @@ final class AdminOrganisationController extends Controller ->whereIn('event_id', $organisation->events()->select('id')) ->count(); - return $this->success(new AdminOrganisationResource($organisation)); + $members = $organisation->users()->orderBy('first_name')->get(); + + return $this->success([ + 'organisation' => new AdminOrganisationResource($organisation), + 'members' => MemberResource::collection($members), + ]); } public function store(): void @@ -91,4 +101,104 @@ final class AdminOrganisationController extends Controller return response()->json(null, 204); } + + public function invite(AdminInviteMemberRequest $request, string $organisationId): JsonResponse + { + $organisation = Organisation::withoutGlobalScopes()->findOrFail($organisationId); + $email = $request->validated('email'); + $role = $request->validated('role'); + + // Check if already a member + $existingMember = $organisation->users()->where('email', $email)->first(); + + if ($existingMember) { + return $this->error('Gebruiker is al lid van deze organisatie.', 422); + } + + // If user with this email already exists, add directly + $existingUser = User::where('email', $email)->first(); + + if ($existingUser) { + $organisation->users()->attach($existingUser, ['role' => $role]); + + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($organisation) + ->event('admin.organisation.member_invited') + ->withProperties(['email' => $email, 'role' => $role, 'direct' => true]) + ->log("Added existing user {$email} as {$role}"); + + return $this->success( + new MemberResource($organisation->users()->where('user_id', $existingUser->id)->first()), + 'Gebruiker direct toegevoegd als lid.', + ); + } + + // Create invitation for unknown email + $invitation = new UserInvitation(['email' => $email]); + $invitation->invited_by_user_id = auth()->id(); + $invitation->organisation_id = $organisation->id; + $invitation->role = $role; + $invitation->token = hash('sha256', bin2hex(random_bytes(32))); + $invitation->status = 'pending'; + $invitation->expires_at = now()->addDays(7); + $invitation->save(); + + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($organisation) + ->event('admin.organisation.member_invited') + ->withProperties(['email' => $email, 'role' => $role, 'direct' => false]) + ->log("Invited {$email} as {$role}"); + + return $this->created(null, 'Uitnodiging aangemaakt.'); + } + + public function removeMember(string $organisationId, User $user): JsonResponse + { + $organisation = Organisation::withoutGlobalScopes()->findOrFail($organisationId); + + if (auth()->id() === $user->id) { + return $this->error('Je kunt jezelf niet verwijderen.', 422); + } + + if (!$organisation->users()->where('user_id', $user->id)->exists()) { + return $this->notFound('Gebruiker is geen lid van deze organisatie.'); + } + + $organisation->users()->detach($user->id); + + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($organisation) + ->event('admin.organisation.member_removed') + ->withProperties(['user_id' => $user->id, 'email' => $user->email]) + ->log("Removed {$user->email} from organisation"); + + return response()->json(null, 204); + } + + public function updateMemberRole(AdminUpdateMemberRoleRequest $request, string $organisationId, User $user): JsonResponse + { + $organisation = Organisation::withoutGlobalScopes()->findOrFail($organisationId); + + if (!$organisation->users()->where('user_id', $user->id)->exists()) { + return $this->notFound('Gebruiker is geen lid van deze organisatie.'); + } + + $organisation->users()->updateExistingPivot($user->id, [ + 'role' => $request->validated('role'), + ]); + + activity('admin') + ->causedBy(auth()->user()) + ->performedOn($organisation) + ->event('admin.organisation.member_role_updated') + ->withProperties(['user_id' => $user->id, 'role' => $request->validated('role')]) + ->log("Updated role of {$user->email} to {$request->validated('role')}"); + + return $this->success( + new MemberResource($organisation->users()->where('user_id', $user->id)->first()), + ); + } } diff --git a/api/app/Http/Requests/Admin/AdminInviteMemberRequest.php b/api/app/Http/Requests/Admin/AdminInviteMemberRequest.php new file mode 100644 index 00000000..50888856 --- /dev/null +++ b/api/app/Http/Requests/Admin/AdminInviteMemberRequest.php @@ -0,0 +1,24 @@ + */ + public function rules(): array + { + return [ + 'email' => ['required', 'email', 'max:255'], + 'role' => ['required', 'in:org_admin,org_member'], + ]; + } +} diff --git a/api/app/Http/Requests/Admin/AdminUpdateMemberRoleRequest.php b/api/app/Http/Requests/Admin/AdminUpdateMemberRoleRequest.php new file mode 100644 index 00000000..439005dc --- /dev/null +++ b/api/app/Http/Requests/Admin/AdminUpdateMemberRoleRequest.php @@ -0,0 +1,23 @@ + */ + public function rules(): array + { + return [ + 'role' => ['required', 'in:org_admin,org_member'], + ]; + } +} diff --git a/api/routes/api.php b/api/routes/api.php index d2720bf1..26fa992c 100644 --- a/api/routes/api.php +++ b/api/routes/api.php @@ -90,6 +90,9 @@ Route::prefix('admin') ->group(function () { // Organisations Route::apiResource('organisations', AdminOrganisationController::class); + Route::post('organisations/{organisation}/invite', [AdminOrganisationController::class, 'invite']); + Route::put('organisations/{organisation}/members/{user}', [AdminOrganisationController::class, 'updateMemberRole']); + Route::delete('organisations/{organisation}/members/{user}', [AdminOrganisationController::class, 'removeMember']); // Users Route::apiResource('users', AdminUserController::class) diff --git a/api/tests/Feature/Api/V1/Admin/AdminOrganisationControllerTest.php b/api/tests/Feature/Api/V1/Admin/AdminOrganisationControllerTest.php index 98acd013..3dc1b1b6 100644 --- a/api/tests/Feature/Api/V1/Admin/AdminOrganisationControllerTest.php +++ b/api/tests/Feature/Api/V1/Admin/AdminOrganisationControllerTest.php @@ -8,6 +8,7 @@ use App\Models\Event; use App\Models\Organisation; use App\Models\Person; use App\Models\User; +use App\Models\UserInvitation; use Database\Seeders\RoleSeeder; use Illuminate\Foundation\Testing\RefreshDatabase; use Laravel\Sanctum\Sanctum; @@ -100,7 +101,7 @@ class AdminOrganisationControllerTest extends TestCase // ─── Show ──────────────────────────────────────────────── - public function test_show_returns_organisation_with_counts(): void + public function test_show_returns_organisation_with_members(): void { $event = Event::factory()->create(['organisation_id' => $this->organisation->id]); @@ -109,12 +110,17 @@ class AdminOrganisationControllerTest extends TestCase $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->assertJsonPath('data.organisation.id', $this->organisation->id); + $response->assertJsonPath('data.organisation.events_count', 1); + $response->assertJsonPath('data.organisation.users_count', 1); $response->assertJsonStructure([ - 'data' => ['id', 'name', 'slug', 'billing_status', 'billing_status_label', 'events_count', 'users_count', 'total_persons'], + 'data' => [ + 'organisation' => ['id', 'name', 'slug', 'billing_status', 'billing_status_label', 'events_count', 'users_count', 'total_persons'], + 'members' => [['id', 'first_name', 'last_name', 'email', 'role']], + ], ]); + $this->assertCount(1, $response->json('data.members')); + $response->assertJsonPath('data.members.0.email', $this->orgAdmin->email); } // ─── Update ────────────────────────────────────────────── @@ -146,4 +152,115 @@ class AdminOrganisationControllerTest extends TestCase $response->assertNoContent(); $this->assertSoftDeleted('organisations', ['id' => $this->organisation->id]); } + + // ─── Invite ────────────────────────────────────────────── + + public function test_invite_creates_invitation_for_new_email(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson("/api/v1/admin/organisations/{$this->organisation->id}/invite", [ + 'email' => 'newuser@example.com', + 'role' => 'org_member', + ]); + + $response->assertCreated(); + $this->assertDatabaseHas('user_invitations', [ + 'email' => 'newuser@example.com', + 'organisation_id' => $this->organisation->id, + 'role' => 'org_member', + 'status' => 'pending', + ]); + } + + public function test_invite_adds_existing_user_directly(): void + { + $existingUser = User::factory()->create(['email' => 'exists@example.com']); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson("/api/v1/admin/organisations/{$this->organisation->id}/invite", [ + 'email' => 'exists@example.com', + 'role' => 'org_admin', + ]); + + $response->assertOk(); + $this->assertDatabaseHas('organisation_user', [ + 'user_id' => $existingUser->id, + 'organisation_id' => $this->organisation->id, + 'role' => 'org_admin', + ]); + } + + public function test_invite_fails_for_existing_member(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->postJson("/api/v1/admin/organisations/{$this->organisation->id}/invite", [ + 'email' => $this->orgAdmin->email, + 'role' => 'org_member', + ]); + + $response->assertUnprocessable(); + $response->assertJsonPath('message', 'Gebruiker is al lid van deze organisatie.'); + } + + public function test_invite_denied_for_non_super_admin(): void + { + Sanctum::actingAs($this->orgAdmin); + + $response = $this->postJson("/api/v1/admin/organisations/{$this->organisation->id}/invite", [ + 'email' => 'test@example.com', + 'role' => 'org_member', + ]); + + $response->assertForbidden(); + } + + // ─── Remove Member ─────────────────────────────────────── + + public function test_remove_member_detaches_from_org(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->deleteJson("/api/v1/admin/organisations/{$this->organisation->id}/members/{$this->orgAdmin->id}"); + + $response->assertNoContent(); + $this->assertDatabaseMissing('organisation_user', [ + 'user_id' => $this->orgAdmin->id, + 'organisation_id' => $this->organisation->id, + ]); + } + + public function test_remove_self_fails(): void + { + // Attach super admin to the org so the self-removal check kicks in + $this->organisation->users()->attach($this->superAdmin, ['role' => 'org_admin']); + + Sanctum::actingAs($this->superAdmin); + + $response = $this->deleteJson("/api/v1/admin/organisations/{$this->organisation->id}/members/{$this->superAdmin->id}"); + + $response->assertUnprocessable(); + $response->assertJsonPath('message', 'Je kunt jezelf niet verwijderen.'); + } + + // ─── Update Member Role ────────────────────────────────── + + public function test_update_member_role(): void + { + Sanctum::actingAs($this->superAdmin); + + $response = $this->putJson("/api/v1/admin/organisations/{$this->organisation->id}/members/{$this->orgAdmin->id}", [ + 'role' => 'org_member', + ]); + + $response->assertOk(); + $response->assertJsonPath('data.role', 'org_member'); + $this->assertDatabaseHas('organisation_user', [ + 'user_id' => $this->orgAdmin->id, + 'organisation_id' => $this->organisation->id, + 'role' => 'org_member', + ]); + } } diff --git a/apps/app/src/composables/api/useAdmin.ts b/apps/app/src/composables/api/useAdmin.ts index 4a5ba5c3..a187dde6 100644 --- a/apps/app/src/composables/api/useAdmin.ts +++ b/apps/app/src/composables/api/useAdmin.ts @@ -4,11 +4,15 @@ import { apiClient } from '@/lib/axios' import type { ActivityLogEntry, AdminOrganisation, + AdminOrganisationDetail, + AdminOrganisationMember, AdminUser, ImpersonationResponse, + InviteMemberPayload, PlatformStats, UpdateAdminOrganisationPayload, UpdateAdminUserPayload, + UpdateMemberRolePayload, } from '@/types/admin' interface ApiResponse { @@ -47,7 +51,7 @@ export function useAdminOrganisation(id: Ref) { return useQuery({ queryKey: ['admin', 'organisations', id], queryFn: async () => { - const { data } = await apiClient.get>( + const { data } = await apiClient.get>( `/admin/organisations/${id.value}`, ) return data.data @@ -86,6 +90,55 @@ export function useDeleteAdminOrganisation() { }) } +// ─── Organisation Members ────────────────────────────────── + +export function useInviteOrganisationMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ organisationId, payload }: { organisationId: string; payload: InviteMemberPayload }) => { + const { data } = await apiClient.post>( + `/admin/organisations/${organisationId}/invite`, + payload, + ) + return data + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['admin', 'organisations', variables.organisationId] }) + }, + }) +} + +export function useRemoveOrganisationMember() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ organisationId, userId }: { organisationId: string; userId: string }) => { + await apiClient.delete(`/admin/organisations/${organisationId}/members/${userId}`) + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['admin', 'organisations', variables.organisationId] }) + }, + }) +} + +export function useUpdateOrganisationMemberRole() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: async ({ organisationId, userId, payload }: { organisationId: string; userId: string; payload: UpdateMemberRolePayload }) => { + const { data } = await apiClient.put>( + `/admin/organisations/${organisationId}/members/${userId}`, + payload, + ) + return data.data + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['admin', 'organisations', variables.organisationId] }) + }, + }) +} + // ─── Users ────────────────────────────────────────────────── export function useAdminUsers(params: Ref>) { diff --git a/apps/app/src/pages/platform/organisations/[id].vue b/apps/app/src/pages/platform/organisations/[id].vue index ff32251e..a57441bb 100644 --- a/apps/app/src/pages/platform/organisations/[id].vue +++ b/apps/app/src/pages/platform/organisations/[id].vue @@ -1,7 +1,15 @@