Skip to content

Commit dc021e4

Browse files
authored
feat: add user trash restore (#4)
1 parent 75f9943 commit dc021e4

8 files changed

Lines changed: 248 additions & 12 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<?php
2+
3+
use Illuminate\Database\Migrations\Migration;
4+
use Illuminate\Database\Schema\Blueprint;
5+
use Illuminate\Support\Facades\Schema;
6+
7+
return new class extends Migration {
8+
public function up()
9+
{
10+
Schema::table('users', function (Blueprint $table) {
11+
$table->softDeletes(); // ✅ Adds `deleted_at` column
12+
});
13+
}
14+
15+
public function down()
16+
{
17+
Schema::table('users', function (Blueprint $table) {
18+
$table->dropSoftDeletes();
19+
});
20+
}
21+
};

src/EclipseServiceProvider.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ public function register(): self
4848
$event->user->updateLoginTracking();
4949
}
5050
});
51-
51+
5252
$this->app->register(AdminPanelProvider::class);
5353

5454
if ($this->app->environment('local')) {

src/Filament/Resources/UserResource.php

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use Filament\Tables\Filters\QueryBuilder\Constraints\TextConstraint;
2121
use Filament\Tables\Table;
2222
use Illuminate\Database\Eloquent\Builder;
23+
use Illuminate\Database\Eloquent\SoftDeletingScope;
2324
use Illuminate\Support\Facades\Hash;
2425

2526
class UserResource extends Resource implements HasShieldPermissions
@@ -160,6 +161,7 @@ public static function table(Table $table): Table
160161
TextConstraint::make('login_count')
161162
->label('Total Logins'),
162163
]),
164+
Tables\Filters\TrashedFilter::make()
163165
];
164166

165167
return $table
@@ -170,7 +172,11 @@ public static function table(Table $table): Table
170172
Tables\Actions\ViewAction::make(),
171173
Tables\Actions\EditAction::make(),
172174
Tables\Actions\DeleteAction::make()
173-
->disabled(fn (User $user) => $user->id === auth()->user()->id),
175+
->authorize(fn (User $record) => auth()->user()->can('delete_user') && auth()->id() !== $record->id)
176+
->requiresConfirmation(),
177+
Tables\Actions\RestoreAction::make()
178+
->visible(fn (User $user) => $user->trashed() && auth()->user()->can('restore_user'))
179+
->requiresConfirmation(),
174180
]),
175181
])
176182
->bulkActions([
@@ -273,6 +279,14 @@ public static function getGloballySearchableAttributes(): array
273279
];
274280
}
275281

282+
public static function getEloquentQuery(): Builder
283+
{
284+
return parent::getEloquentQuery()
285+
->withoutGlobalScopes([
286+
SoftDeletingScope::class,
287+
]);
288+
}
289+
276290
public static function getPermissionPrefixes(): array
277291
{
278292
return [
@@ -282,6 +296,10 @@ public static function getPermissionPrefixes(): array
282296
'update',
283297
'delete',
284298
'delete_any',
299+
'restore',
300+
'restore_any',
301+
'force_delete',
302+
'force_delete_any',
285303
];
286304
}
287305
}

src/Models/User.php

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Spatie\MediaLibrary\HasMedia;
1616
use Spatie\MediaLibrary\InteractsWithMedia;
1717
use Spatie\Permission\Traits\HasRoles;
18+
use Illuminate\Database\Eloquent\SoftDeletes;
1819

1920
/**
2021
* @property int $id
@@ -27,12 +28,14 @@
2728
* @property string|null $remember_token
2829
* @property string|null $created_at
2930
* @property string|null $updated_at
31+
* @property string|null $deleted_at
3032
*/
3133
class User extends Authenticatable implements FilamentUser, HasAvatar, HasMedia, HasTenants
3234
{
33-
use HasFactory, HasRoles, InteractsWithMedia, Notifiable;
35+
use HasFactory, HasRoles, InteractsWithMedia, Notifiable, SoftDeletes;
3436

3537
protected $table = 'users';
38+
protected $dates = ['deleted_at'];
3639

3740
/**
3841
* The attributes that are mass assignable.
@@ -112,6 +115,12 @@ protected static function booted()
112115
static::saving(function (self $user) {
113116
$user->name = trim("$user->first_name $user->last_name");
114117
});
118+
119+
static::retrieved(function (self $user) {
120+
if ($user->trashed() && auth()->check() && request()->routeIs('login')) {
121+
throw new \Exception('This account has been deactivated.');
122+
}
123+
});
115124
}
116125

117126
/**
@@ -125,4 +134,19 @@ public function updateLoginTracking()
125134
$this->increment('login_count');
126135
$this->save();
127136
}
137+
138+
/**
139+
* Delete the user account, preventing self-deletion.
140+
*
141+
* @throws \Exception If the user attempts to delete their own account.
142+
* @return bool|null
143+
*/
144+
public function delete(): ?bool
145+
{
146+
if ($this->id === auth()->id()) {
147+
throw new \Exception('You cannot delete your own account.');
148+
}
149+
150+
return parent::delete();
151+
}
128152
}

src/Policies/UserPolicy.php

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,13 @@ public function update(User $user): bool
4444
/**
4545
* Determine whether the user can delete the model.
4646
*/
47-
public function delete(User $user): bool
47+
public function delete(User $authenticatedUser, User $user): bool
4848
{
49-
return $user->can('delete_user');
49+
if ($authenticatedUser->id === $user->id) {
50+
return false;
51+
}
52+
53+
return $authenticatedUser->can('delete_user');
5054
}
5155

5256
/**
@@ -56,4 +60,36 @@ public function deleteAny(User $user): bool
5660
{
5761
return $user->can('delete_any_user');
5862
}
59-
}
63+
64+
/**
65+
* Determine whether the user can restore the model.
66+
*/
67+
public function restore(User $user): bool
68+
{
69+
return $user->can('restore_user');
70+
}
71+
72+
/**
73+
* Determine whether the user can bulk restore.
74+
*/
75+
public function restoreAny(User $user): bool
76+
{
77+
return $user->can('restore_any_user');
78+
}
79+
80+
/**
81+
* Determine whether the user can permanently delete the model.
82+
*/
83+
public function forceDelete(User $user): bool
84+
{
85+
return $user->can('force_delete_user');
86+
}
87+
88+
/**
89+
* Determine whether the user can permanently bulk delete.
90+
*/
91+
public function forceDeleteAny(User $user): bool
92+
{
93+
return $user->can('force_delete_any_user');
94+
}
95+
}

tests/Feature/Filament/Resources/UserResourceTest.php

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use Filament\Tables\Actions\DeleteAction;
88
use Filament\Tables\Actions\DeleteBulkAction;
99
use Illuminate\Support\Facades\Hash;
10+
use Spatie\Permission\Models\Permission;
1011

1112
use function Pest\Livewire\livewire;
1213

@@ -124,23 +125,25 @@
124125

125126
test('user can be deleted', function () {
126127
$user = User::factory()->create();
127-
128+
128129
livewire(ListUsers::class)
130+
->assertSuccessful()
129131
->assertTableActionExists(DeleteAction::class)
130132
->assertTableActionEnabled(DeleteAction::class, $user)
131133
->callTableAction(DeleteAction::class, $user);
132134

133-
$this->assertModelMissing($user);
135+
$this->assertSoftDeleted('users', ['id' => $user->id]);
134136
});
135137

136138
test('authed user cannot delete himself', function () {
139+
$superAdmin = User::withTrashed()->find($this->superAdmin->id);
137140

138141
// Assert on table row action
139142
livewire(ListUsers::class)
140-
->assertTableActionDisabled(DeleteAction::class, $this->superAdmin);
143+
->assertTableActionDisabled(DeleteAction::class, $superAdmin);
141144

142145
// Assert on bulk delete
143-
$users = User::all();
146+
$users = User::all();
144147

145148
livewire(ListUsers::class)
146149
->callTableBulkAction(DeleteBulkAction::class, $users)
@@ -149,4 +152,4 @@
149152
foreach ($users as $user) {
150153
$this->assertModelExists($user);
151154
}
152-
});
155+
});

tests/Feature/LoginTrackingTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
<?php
22

33
use Eclipse\Core\Models\User;
4-
use Illuminate\Foundation\Testing\RefreshDatabase;
54
use Illuminate\Support\Facades\Auth;
5+
use Illuminate\Foundation\Testing\RefreshDatabase;
66

77
uses(RefreshDatabase::class);
88

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,134 @@
1+
<?php
2+
3+
use Eclipse\Core\Models\User;
4+
use Illuminate\Foundation\Testing\RefreshDatabase;
5+
use Illuminate\Support\Facades\Auth;
6+
use Illuminate\Support\Facades\Gate;
7+
use Illuminate\Auth\Access\AuthorizationException;
8+
9+
uses(RefreshDatabase::class);
10+
11+
beforeEach(function () {
12+
$this->set_up_super_admin_and_tenant();
13+
});
14+
15+
test('authorized user with permission can trash another user', function () {
16+
$user = User::factory()->create();
17+
Auth::login($this->superAdmin);
18+
$this->assertTrue($this->superAdmin->hasPermissionTo('delete_user'));
19+
$this->assertTrue($this->superAdmin->can('delete', $user));
20+
$user->delete();
21+
$this->assertTrue($user->fresh()->trashed());
22+
});
23+
24+
test('non-authorized user cannot trash another user', function () {
25+
$user = User::factory()->create();
26+
$targetUser = User::factory()->create();
27+
Auth::login($user);
28+
$this->assertFalse($user->hasPermissionTo('delete_user'));
29+
$this->assertFalse($user->can('delete', $targetUser));
30+
$this->expectException(AuthorizationException::class);
31+
Gate::authorize('delete', $targetUser);
32+
});
33+
34+
test('user cannot trash himself', function () {
35+
Auth::login($this->superAdmin);
36+
$this->assertFalse($this->superAdmin->can('delete', $this->superAdmin));
37+
try {
38+
Gate::authorize('delete', $this->superAdmin);
39+
$this->fail('User was able to authorize self-deletion, which should not be allowed');
40+
} catch (AuthorizationException $e) {
41+
$this->assertTrue(true);
42+
}
43+
$this->assertFalse($this->superAdmin->fresh()->trashed());
44+
});
45+
46+
test('authorized user with restore permission can restore a trashed user', function () {
47+
$user = User::factory()->create();
48+
$user->delete();
49+
Auth::login($this->superAdmin);
50+
$this->assertTrue($this->superAdmin->hasPermissionTo('restore_user'));
51+
$this->assertTrue($this->superAdmin->can('restore', $user));
52+
$user->restore();
53+
$this->assertFalse($user->fresh()->trashed());
54+
});
55+
56+
test('authorized user with restore_any permission can restore any trashed user', function () {
57+
$userToTrash = User::factory()->create();
58+
$userToTrash->delete();
59+
$limitedAdmin = User::factory()->create();
60+
$limitedAdmin->givePermissionTo('restore_any_user');
61+
Auth::login($limitedAdmin);
62+
$this->assertTrue($limitedAdmin->hasPermissionTo('restore_any_user'));
63+
$this->assertTrue($limitedAdmin->can('restoreAny', User::class));
64+
$userToTrash->restore();
65+
$this->assertFalse($userToTrash->fresh()->trashed());
66+
});
67+
68+
test('non-authorized user cannot restore another user', function () {
69+
$userToTrash = User::factory()->create();
70+
$userToTrash->delete();
71+
$nonAuthorizedUser = User::factory()->create();
72+
Auth::login($nonAuthorizedUser);
73+
$this->assertFalse($nonAuthorizedUser->hasPermissionTo('restore_user'));
74+
$this->assertFalse($nonAuthorizedUser->can('restore', $userToTrash));
75+
$this->expectException(AuthorizationException::class);
76+
Gate::authorize('restore', $userToTrash);
77+
});
78+
79+
test('trashed user cannot login', function () {
80+
$userToTrash = User::factory()->create([
81+
'email' => 'trashed@example.com',
82+
'password' => bcrypt('password')
83+
]);
84+
$userToTrash->delete();
85+
Auth::logout();
86+
$attempt = Auth::attempt([
87+
'email' => 'trashed@example.com',
88+
'password' => 'password'
89+
]);
90+
$this->assertFalse($attempt);
91+
});
92+
93+
test('authorized user with permission can force delete a trashed user', function () {
94+
$user = User::factory()->create();
95+
$user->delete();
96+
Auth::login($this->superAdmin);
97+
$this->assertTrue($this->superAdmin->hasPermissionTo('force_delete_user'));
98+
$this->assertTrue($this->superAdmin->can('forceDelete', $user));
99+
$user->forceDelete();
100+
$this->assertNull(User::withTrashed()->find($user->id));
101+
});
102+
103+
test('non-authorized user cannot force delete a trashed user', function () {
104+
$userToTrash = User::factory()->create();
105+
$userToTrash->delete();
106+
$nonAuthorizedUser = User::factory()->create();
107+
Auth::login($nonAuthorizedUser);
108+
$this->assertFalse($nonAuthorizedUser->hasPermissionTo('force_delete_user'));
109+
$this->assertFalse($nonAuthorizedUser->can('forceDelete', $userToTrash));
110+
$this->expectException(AuthorizationException::class);
111+
Gate::authorize('forceDelete', $userToTrash);
112+
});
113+
114+
test('can view trashed users when user has permissions', function () {
115+
$trashedUser = User::factory()->create();
116+
$trashedUser->delete();
117+
Auth::login($this->superAdmin);
118+
$this->assertTrue($this->superAdmin->hasPermissionTo('view_any_user'));
119+
$this->assertTrue($this->superAdmin->hasPermissionTo('view_user'));
120+
$this->assertTrue($this->superAdmin->can('viewAny', User::class));
121+
$this->assertTrue($this->superAdmin->can('view', $trashedUser));
122+
});
123+
124+
test('filament resource can handle trashed users', function () {
125+
$userToTrash = User::factory()->create([
126+
'name' => 'Trashed User',
127+
'email' => 'trashed@example.com'
128+
]);
129+
$userToTrash->delete();
130+
Auth::login($this->superAdmin);
131+
$this->assertTrue($this->superAdmin->can('viewAny', User::class));
132+
$this->assertNotNull(User::withTrashed()->where('email', 'trashed@example.com')->first());
133+
$this->assertTrue(User::withTrashed()->where('email', 'trashed@example.com')->first()->trashed());
134+
});

0 commit comments

Comments
 (0)