diff --git a/app/Livewire/Team/AdminView.php b/app/Livewire/Team/AdminView.php index c98e972fd6..85f5c1c683 100644 --- a/app/Livewire/Team/AdminView.php +++ b/app/Livewire/Team/AdminView.php @@ -2,6 +2,7 @@ namespace App\Livewire\Team; +use App\Enums\Role; use App\Models\User; use Livewire\Component; use Livewire\WithPagination; @@ -79,6 +80,10 @@ class AdminView extends Component return $this->dispatch('error', 'User not found'); } + if ($error = $this->deletionError(auth()->user(), $user)) { + return $this->dispatch('error', $error); + } + try { $user->delete(); auditLog('ui.user.deleted', [ @@ -96,6 +101,38 @@ class AdminView extends Component } } + /** + * Instance admins may delete other users, but never the root user, their + * own account (use the profile instead), or a user whose role in the root + * team is higher than their own. + */ + private function deletionError(User $actor, User $target): ?string + { + if ($target->id === 0) { + return 'The root user cannot be deleted.'; + } + + if ($target->id === $actor->id) { + return 'Delete your own account from your profile.'; + } + + $actorRole = $this->rootTeamRole($actor); + $targetRole = $this->rootTeamRole($target); + + if (! $actorRole || ($targetRole && $targetRole->gt($actorRole))) { + return 'You cannot delete a user with a higher role in the root team.'; + } + + return null; + } + + private function rootTeamRole(User $user): ?Role + { + $role = $user->teams()->where('teams.id', 0)->first()?->pivot?->role; + + return $role ? Role::tryFrom($role) : null; + } + public function render() { abort_unless(isInstanceAdmin(), 403); diff --git a/tests/Feature/AdminViewUserDeletionTest.php b/tests/Feature/AdminViewUserDeletionTest.php new file mode 100644 index 0000000000..4852a96f34 --- /dev/null +++ b/tests/Feature/AdminViewUserDeletionTest.php @@ -0,0 +1,102 @@ + 0]); + + $this->rootTeam = Team::factory()->create(['id' => 0]); + $this->rootUser = User::factory()->create(['id' => 0]); + + $this->rootAdmin = User::factory()->create(); + $this->rootTeam->members()->attach($this->rootAdmin->id, ['role' => 'admin']); + + $this->rootOwner = User::factory()->create(); + $this->rootTeam->members()->attach($this->rootOwner->id, ['role' => 'owner']); +}); + +function actAsRootTeamMemberForAdminViewDeletion(User $user): void +{ + test()->actingAs($user); + session(['currentTeam' => Team::find(0)]); +} + +test('a root team admin cannot delete the root user', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', 0, 'password') + ->assertDispatched('error', 'The root user cannot be deleted.'); + + expect(User::find(0))->not->toBeNull(); +}); + +test('a root team admin cannot delete a root team owner', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootOwner->id, 'password') + ->assertDispatched('error', 'You cannot delete a user with a higher role in the root team.'); + + expect(User::find($this->rootOwner->id))->not->toBeNull(); +}); + +test('a root team owner cannot delete the root user', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootOwner); + + Livewire::test(AdminView::class) + ->call('delete', 0, 'password') + ->assertDispatched('error', 'The root user cannot be deleted.'); + + expect(User::find(0))->not->toBeNull(); +}); + +test('an instance admin cannot delete their own account from the admin view', function () { + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootAdmin->id, 'password') + ->assertDispatched('error', 'Delete your own account from your profile.'); + + expect(User::find($this->rootAdmin->id))->not->toBeNull(); +}); + +test('a root team owner can delete a root team admin and a member', function () { + $member = User::factory()->create(); + $this->rootTeam->members()->attach($member->id, ['role' => 'member']); + + actAsRootTeamMemberForAdminViewDeletion($this->rootOwner); + + Livewire::test(AdminView::class) + ->call('delete', $this->rootAdmin->id, 'password') + ->assertReturned(true) + ->call('delete', $member->id, 'password') + ->assertReturned(true); + + expect(User::find($this->rootAdmin->id))->toBeNull() + ->and(User::find($member->id))->toBeNull(); +}); + +test('a root team admin can delete another root team admin and a user outside the root team', function () { + $otherAdmin = User::factory()->create(); + $this->rootTeam->members()->attach($otherAdmin->id, ['role' => 'admin']); + $outsider = User::factory()->create(); + + actAsRootTeamMemberForAdminViewDeletion($this->rootAdmin); + + Livewire::test(AdminView::class) + ->call('delete', $otherAdmin->id, 'password') + ->assertReturned(true) + ->call('delete', $outsider->id, 'password') + ->assertReturned(true); + + expect(User::find($otherAdmin->id))->toBeNull() + ->and(User::find($outsider->id))->toBeNull(); +});