Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration {
public function up()
{
Schema::table('users', function (Blueprint $table) {
$table->softDeletes(); // ✅ Adds `deleted_at` column
});
}

public function down()
{
Schema::table('users', function (Blueprint $table) {
$table->dropSoftDeletes();
});
}
};
2 changes: 1 addition & 1 deletion src/EclipseServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ public function register(): self
$event->user->updateLoginTracking();
}
});

$this->app->register(AdminPanelProvider::class);

if ($this->app->environment('local')) {
Expand Down
20 changes: 19 additions & 1 deletion src/Filament/Resources/UserResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,6 +161,7 @@ public static function table(Table $table): Table
TextConstraint::make('login_count')
->label('Total Logins'),
]),
Tables\Filters\TrashedFilter::make()
];

return $table
Expand All @@ -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([
Expand Down Expand Up @@ -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 [
Expand All @@ -282,6 +296,10 @@ public static function getPermissionPrefixes(): array
'update',
'delete',
'delete_any',
'restore',
'restore_any',
'force_delete',
'force_delete_any',
];
}
}
26 changes: 25 additions & 1 deletion src/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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.');
}
});
}

/**
Expand All @@ -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();
}
}
42 changes: 39 additions & 3 deletions src/Policies/UserPolicy.php
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}

/**
Expand All @@ -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');
}
}
13 changes: 8 additions & 5 deletions tests/Feature/Filament/Resources/UserResourceTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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)
Expand All @@ -149,4 +152,4 @@
foreach ($users as $user) {
$this->assertModelExists($user);
}
});
});
2 changes: 1 addition & 1 deletion tests/Feature/LoginTrackingTest.php
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
<?php

use Eclipse\Core\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Foundation\Testing\RefreshDatabase;

uses(RefreshDatabase::class);

Expand Down
134 changes: 134 additions & 0 deletions tests/Feature/UserTrashRestoreTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
<?php

use Eclipse\Core\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Auth\Access\AuthorizationException;

uses(RefreshDatabase::class);

beforeEach(function () {
$this->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());
});