diff --git a/database/migrations/2025_03_15_000001_add_soft_deletes_to_users.php b/database/migrations/2025_03_15_000001_add_soft_deletes_to_users.php new file mode 100644 index 0000000..10ba119 --- /dev/null +++ b/database/migrations/2025_03_15_000001_add_soft_deletes_to_users.php @@ -0,0 +1,21 @@ +softDeletes(); // ✅ Adds `deleted_at` column + }); + } + + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +}; diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index 99aa772..1ed7174 100644 --- a/src/EclipseServiceProvider.php +++ b/src/EclipseServiceProvider.php @@ -48,7 +48,7 @@ public function register(): self $event->user->updateLoginTracking(); } }); - + $this->app->register(AdminPanelProvider::class); if ($this->app->environment('local')) { diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index 4d09de0..13b158d 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -20,6 +20,7 @@ use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint; use Filament\Tables\Table; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\SoftDeletingScope; use Illuminate\Support\Facades\Hash; class UserResource extends Resource implements HasShieldPermissions @@ -160,6 +161,7 @@ public static function table(Table $table): Table TextConstraint::make('login_count') ->label('Total Logins'), ]), + Tables\Filters\TrashedFilter::make() ]; return $table @@ -170,7 +172,11 @@ public static function table(Table $table): Table Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), Tables\Actions\DeleteAction::make() - ->disabled(fn (User $user) => $user->id === auth()->user()->id), + ->authorize(fn (User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id) + ->requiresConfirmation(), + Tables\Actions\RestoreAction::make() + ->visible(fn (User $user) => $user->trashed() && auth()->user()->can('restore_user')) + ->requiresConfirmation(), ]), ]) ->bulkActions([ @@ -273,6 +279,14 @@ public static function getGloballySearchableAttributes(): array ]; } + public static function getEloquentQuery(): Builder + { + return parent::getEloquentQuery() + ->withoutGlobalScopes([ + SoftDeletingScope::class, + ]); + } + public static function getPermissionPrefixes(): array { return [ @@ -282,6 +296,10 @@ public static function getPermissionPrefixes(): array 'update', 'delete', 'delete_any', + 'restore', + 'restore_any', + 'force_delete', + 'force_delete_any', ]; } } diff --git a/src/Models/User.php b/src/Models/User.php index b223ee3..a7f5dab 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -15,6 +15,7 @@ use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; +use Illuminate\Database\Eloquent\SoftDeletes; /** * @property int $id @@ -27,12 +28,14 @@ * @property string|null $remember_token * @property string|null $created_at * @property string|null $updated_at + * @property string|null $deleted_at */ class User extends Authenticatable implements FilamentUser, HasAvatar, HasMedia, HasTenants { - use HasFactory, HasRoles, InteractsWithMedia, Notifiable; + use HasFactory, HasRoles, InteractsWithMedia, Notifiable, SoftDeletes; protected $table = 'users'; + protected $dates = ['deleted_at']; /** * The attributes that are mass assignable. @@ -112,6 +115,12 @@ protected static function booted() static::saving(function (self $user) { $user->name = trim("$user->first_name $user->last_name"); }); + + static::retrieved(function (self $user) { + if ($user->trashed() && auth()->check() && request()->routeIs('login')) { + throw new \Exception('This account has been deactivated.'); + } + }); } /** @@ -125,4 +134,19 @@ public function updateLoginTracking() $this->increment('login_count'); $this->save(); } + + /** + * Delete the user account, preventing self-deletion. + * + * @throws \Exception If the user attempts to delete their own account. + * @return bool|null + */ + public function delete(): ?bool + { + if ($this->id === auth()->id()) { + throw new \Exception('You cannot delete your own account.'); + } + + return parent::delete(); + } } diff --git a/src/Policies/UserPolicy.php b/src/Policies/UserPolicy.php index b07f6b3..415dbb0 100644 --- a/src/Policies/UserPolicy.php +++ b/src/Policies/UserPolicy.php @@ -44,9 +44,13 @@ public function update(User $user): bool /** * Determine whether the user can delete the model. */ - public function delete(User $user): bool + public function delete(User $authenticatedUser, User $user): bool { - return $user->can('delete_user'); + if ($authenticatedUser->id === $user->id) { + return false; + } + + return $authenticatedUser->can('delete_user'); } /** @@ -56,4 +60,36 @@ public function deleteAny(User $user): bool { return $user->can('delete_any_user'); } -} + + /** + * Determine whether the user can restore the model. + */ + public function restore(User $user): bool + { + return $user->can('restore_user'); + } + + /** + * Determine whether the user can bulk restore. + */ + public function restoreAny(User $user): bool + { + return $user->can('restore_any_user'); + } + + /** + * Determine whether the user can permanently delete the model. + */ + public function forceDelete(User $user): bool + { + return $user->can('force_delete_user'); + } + + /** + * Determine whether the user can permanently bulk delete. + */ + public function forceDeleteAny(User $user): bool + { + return $user->can('force_delete_any_user'); + } +} \ No newline at end of file diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php index b5f58d9..54b3c0f 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -7,6 +7,7 @@ use Filament\Tables\Actions\DeleteAction; use Filament\Tables\Actions\DeleteBulkAction; use Illuminate\Support\Facades\Hash; +use Spatie\Permission\Models\Permission; use function Pest\Livewire\livewire; @@ -124,23 +125,25 @@ test('user can be deleted', function () { $user = User::factory()->create(); - + livewire(ListUsers::class) + ->assertSuccessful() ->assertTableActionExists(DeleteAction::class) ->assertTableActionEnabled(DeleteAction::class, $user) ->callTableAction(DeleteAction::class, $user); - $this->assertModelMissing($user); + $this->assertSoftDeleted('users', ['id' => $user->id]); }); test('authed user cannot delete himself', function () { + $superAdmin = User::withTrashed()->find($this->superAdmin->id); // Assert on table row action livewire(ListUsers::class) - ->assertTableActionDisabled(DeleteAction::class, $this->superAdmin); + ->assertTableActionDisabled(DeleteAction::class, $superAdmin); // Assert on bulk delete - $users = User::all(); + $users = User::all(); livewire(ListUsers::class) ->callTableBulkAction(DeleteBulkAction::class, $users) @@ -149,4 +152,4 @@ foreach ($users as $user) { $this->assertModelExists($user); } -}); +}); \ No newline at end of file diff --git a/tests/Feature/LoginTrackingTest.php b/tests/Feature/LoginTrackingTest.php index 930b05c..54cdab4 100644 --- a/tests/Feature/LoginTrackingTest.php +++ b/tests/Feature/LoginTrackingTest.php @@ -1,8 +1,8 @@ set_up_super_admin_and_tenant(); +}); + +test('authorized user with permission can trash another user', function () { + $user = User::factory()->create(); + Auth::login($this->superAdmin); + $this->assertTrue($this->superAdmin->hasPermissionTo('delete_user')); + $this->assertTrue($this->superAdmin->can('delete', $user)); + $user->delete(); + $this->assertTrue($user->fresh()->trashed()); +}); + +test('non-authorized user cannot trash another user', function () { + $user = User::factory()->create(); + $targetUser = User::factory()->create(); + Auth::login($user); + $this->assertFalse($user->hasPermissionTo('delete_user')); + $this->assertFalse($user->can('delete', $targetUser)); + $this->expectException(AuthorizationException::class); + Gate::authorize('delete', $targetUser); +}); + +test('user cannot trash himself', function () { + Auth::login($this->superAdmin); + $this->assertFalse($this->superAdmin->can('delete', $this->superAdmin)); + try { + Gate::authorize('delete', $this->superAdmin); + $this->fail('User was able to authorize self-deletion, which should not be allowed'); + } catch (AuthorizationException $e) { + $this->assertTrue(true); + } + $this->assertFalse($this->superAdmin->fresh()->trashed()); +}); + +test('authorized user with restore permission can restore a trashed user', function () { + $user = User::factory()->create(); + $user->delete(); + Auth::login($this->superAdmin); + $this->assertTrue($this->superAdmin->hasPermissionTo('restore_user')); + $this->assertTrue($this->superAdmin->can('restore', $user)); + $user->restore(); + $this->assertFalse($user->fresh()->trashed()); +}); + +test('authorized user with restore_any permission can restore any trashed user', function () { + $userToTrash = User::factory()->create(); + $userToTrash->delete(); + $limitedAdmin = User::factory()->create(); + $limitedAdmin->givePermissionTo('restore_any_user'); + Auth::login($limitedAdmin); + $this->assertTrue($limitedAdmin->hasPermissionTo('restore_any_user')); + $this->assertTrue($limitedAdmin->can('restoreAny', User::class)); + $userToTrash->restore(); + $this->assertFalse($userToTrash->fresh()->trashed()); +}); + +test('non-authorized user cannot restore another user', function () { + $userToTrash = User::factory()->create(); + $userToTrash->delete(); + $nonAuthorizedUser = User::factory()->create(); + Auth::login($nonAuthorizedUser); + $this->assertFalse($nonAuthorizedUser->hasPermissionTo('restore_user')); + $this->assertFalse($nonAuthorizedUser->can('restore', $userToTrash)); + $this->expectException(AuthorizationException::class); + Gate::authorize('restore', $userToTrash); +}); + +test('trashed user cannot login', function () { + $userToTrash = User::factory()->create([ + 'email' => 'trashed@example.com', + 'password' => bcrypt('password') + ]); + $userToTrash->delete(); + Auth::logout(); + $attempt = Auth::attempt([ + 'email' => 'trashed@example.com', + 'password' => 'password' + ]); + $this->assertFalse($attempt); +}); + +test('authorized user with permission can force delete a trashed user', function () { + $user = User::factory()->create(); + $user->delete(); + Auth::login($this->superAdmin); + $this->assertTrue($this->superAdmin->hasPermissionTo('force_delete_user')); + $this->assertTrue($this->superAdmin->can('forceDelete', $user)); + $user->forceDelete(); + $this->assertNull(User::withTrashed()->find($user->id)); +}); + +test('non-authorized user cannot force delete a trashed user', function () { + $userToTrash = User::factory()->create(); + $userToTrash->delete(); + $nonAuthorizedUser = User::factory()->create(); + Auth::login($nonAuthorizedUser); + $this->assertFalse($nonAuthorizedUser->hasPermissionTo('force_delete_user')); + $this->assertFalse($nonAuthorizedUser->can('forceDelete', $userToTrash)); + $this->expectException(AuthorizationException::class); + Gate::authorize('forceDelete', $userToTrash); +}); + +test('can view trashed users when user has permissions', function () { + $trashedUser = User::factory()->create(); + $trashedUser->delete(); + Auth::login($this->superAdmin); + $this->assertTrue($this->superAdmin->hasPermissionTo('view_any_user')); + $this->assertTrue($this->superAdmin->hasPermissionTo('view_user')); + $this->assertTrue($this->superAdmin->can('viewAny', User::class)); + $this->assertTrue($this->superAdmin->can('view', $trashedUser)); +}); + +test('filament resource can handle trashed users', function () { + $userToTrash = User::factory()->create([ + 'name' => 'Trashed User', + 'email' => 'trashed@example.com' + ]); + $userToTrash->delete(); + Auth::login($this->superAdmin); + $this->assertTrue($this->superAdmin->can('viewAny', User::class)); + $this->assertNotNull(User::withTrashed()->where('email', 'trashed@example.com')->first()); + $this->assertTrue(User::withTrashed()->where('email', 'trashed@example.com')->first()->trashed()); +}); \ No newline at end of file