From c0a81a0c5077b7fe1eeb1e04ead33c188a12db46 Mon Sep 17 00:00:00 2001 From: sinach Date: Sat, 15 Mar 2025 10:29:43 +0100 Subject: [PATCH 1/4] feat: added login tracking and login count features to User model --- .lando.dist.yml => .lando.yml | 0 ..._15_000000_add_login_tracking_to_users.php | 22 +++++++ src/EclipseServiceProvider.php | 3 + src/Filament/Resources/UserResource.php | 14 +++++ src/Models/User.php | 17 +++++ tests/Feature/LoginTrackingTest.php | 62 +++++++++++++++++++ 6 files changed, 118 insertions(+) rename .lando.dist.yml => .lando.yml (100%) create mode 100644 database/migrations/2025_03_15_000000_add_login_tracking_to_users.php create mode 100644 tests/Feature/LoginTrackingTest.php diff --git a/.lando.dist.yml b/.lando.yml similarity index 100% rename from .lando.dist.yml rename to .lando.yml diff --git a/database/migrations/2025_03_15_000000_add_login_tracking_to_users.php b/database/migrations/2025_03_15_000000_add_login_tracking_to_users.php new file mode 100644 index 0000000..103c22e --- /dev/null +++ b/database/migrations/2025_03_15_000000_add_login_tracking_to_users.php @@ -0,0 +1,22 @@ +dateTime('last_login_at')->nullable(); + $table->integer('login_count')->default(0); + }); + } + + public function down() + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn(['last_login_at', 'login_count']); + }); + } +}; diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index 7d8089e..91fe09a 100644 --- a/src/EclipseServiceProvider.php +++ b/src/EclipseServiceProvider.php @@ -27,6 +27,9 @@ public function configurePackage(Package $package): void 'permission', 'telescope', ]) + ->hasMigrations([ + 'add_login_tracking_to_users', + ]) ->discoversMigrations() ->runsMigrations() ->hasTranslations(); diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index 470a480..0461a24 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -86,6 +86,16 @@ public static function table(Table $table): Table ->searchable() ->sortable() ->toggleable(), + Tables\Columns\TextColumn::make('last_login_at') + ->label('Last login') + ->dateTime() + ->sortable() + ->toggleable(), + Tables\Columns\TextColumn::make('login_count') + ->label('Total Logins') + ->sortable() + ->numeric() + ->formatStateUsing(fn (?int $state) => $state ?? 0), ]; if (config('eclipse.email_verification')) { @@ -145,6 +155,10 @@ public static function table(Table $table): Table ->label('Last name'), TextConstraint::make('name') ->label('Full name'), + TextConstraint::make('last_login_at') + ->label('Last login Date'), + TextConstraint::make('login_count') + ->label('Total Logins'), ]), ]; diff --git a/src/Models/User.php b/src/Models/User.php index 252ba57..046db18 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -44,6 +44,8 @@ class User extends Authenticatable implements FilamentUser, HasAvatar, HasMedia, 'last_name', 'email', 'password', + 'last_login_at', + 'login_count', ]; /** @@ -66,6 +68,7 @@ protected function casts(): array return [ 'email_verified_at' => 'datetime', 'password' => 'hashed', + 'last_login_at' => 'datetime', ]; } @@ -109,5 +112,19 @@ protected static function booted() static::saving(function (self $user) { $user->name = trim("$user->first_name $user->last_name"); }); + + static::creating(function (self $user) { + if (is_null($user->login_count)) { + $user->login_count = 0; + } + }); + } + + // Add the following method to the User model: last_login_at & login_count features + public function updateLoginTracking() + { + $this->last_login_at = now(); + $this->increment('login_count'); + $this->save(); } } diff --git a/tests/Feature/LoginTrackingTest.php b/tests/Feature/LoginTrackingTest.php new file mode 100644 index 0000000..60d916b --- /dev/null +++ b/tests/Feature/LoginTrackingTest.php @@ -0,0 +1,62 @@ +create(); + + expect($user->last_login_at)->toBeNull(); + expect($user->login_count ?? 0)->toBe(0); +}); + +test('user login updates last login timestamp and increments count', function () { + $user = User::factory()->create([ + 'last_login_at' => null, + 'login_count' => 0, + ]); + + // Simulate login + Auth::login($user); + $user->updateLoginTracking(); + $user->refresh(); + + expect($user->last_login_at)->not->toBeNull(); + expect($user->login_count)->toBe(1); +}); + +test('multiple logins correctly increment login count', function () { + $user = User::factory()->create([ + 'login_count' => 2, // User has logged in twice before + ]); + + Auth::login($user); + $user->updateLoginTracking(); + $user->refresh(); + + expect($user->login_count)->toBe(3); // Should increase by 1 +}); + +test('login tracking does not reset on logout', function () { + $user = User::factory()->create([ + 'last_login_at' => now()->subDays(1), + 'login_count' => 5, + ]); + + Auth::login($user); + $user->updateLoginTracking(); + Auth::logout(); + $user->refresh(); + + expect($user->last_login_at)->not->toBeNull(); + expect($user->login_count)->toBe(6); // Login count should remain after logout +}); + +test('guest users do not have login tracking data', function () { + $this->get('/admin')->assertRedirect('admin/login'); + + expect(Auth::user())->toBeNull(); +}); From abe1425d6c832761b7e2b6a24c60c218447a3973 Mon Sep 17 00:00:00 2001 From: sinach Date: Sat, 15 Mar 2025 11:15:36 +0100 Subject: [PATCH 2/4] feat: added user trash & restore feature --- ...03_15_000001_add_soft_deletes_to_users.php | 21 ++++++++ src/Filament/Resources/UserResource.php | 14 +++++- src/Models/User.php | 20 +++++++- .../Filament/Resources/UserResourceTest.php | 6 ++- tests/Feature/UserTrashRestoreTest.php | 49 +++++++++++++++++++ 5 files changed, 107 insertions(+), 3 deletions(-) create mode 100644 database/migrations/2025_03_15_000001_add_soft_deletes_to_users.php create mode 100644 tests/Feature/UserTrashRestoreTest.php 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/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index 0461a24..82b4630 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -32,6 +32,8 @@ class UserResource extends Resource implements HasShieldPermissions protected static ?string $recordTitleAttribute = 'first_name'; + protected static bool $softDeletes = true; + public static function form(Form $form): Form { return $form->schema([ @@ -160,6 +162,9 @@ public static function table(Table $table): Table TextConstraint::make('login_count') ->label('Total Logins'), ]), + + // added trash filter + Tables\Filters\TrashedFilter::make() ]; return $table @@ -169,7 +174,14 @@ public static function table(Table $table): Table Tables\Actions\ActionGroup::make([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), - Tables\Actions\DeleteAction::make()->disabled(fn (User $user) => $user->id === auth()->user()->id), + Tables\Actions\DeleteAction::make()->disabled(fn (User $user) => $user->id === auth()->user()->id) + ->visible(fn (User $user) => $user->id !== auth()->user()->id) + ->authorize(fn () => auth()->user()->can('delete', User::class)) + ->requiresConfirmation(), + Tables\Actions\RestoreAction::make() + ->visible(fn (User $user) => $user->trashed()) + ->authorize(fn () => auth()->user()->can('restore', User::class)) + ->requiresConfirmation(), ]), ]) ->bulkActions([ diff --git a/src/Models/User.php b/src/Models/User.php index 046db18..5bca82e 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 @@ -30,9 +31,10 @@ */ 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. @@ -118,6 +120,12 @@ protected static function booted() $user->login_count = 0; } }); + + static::retrieved(function (self $user) { + if ($user->trashed() && request()->routeIs('login')) { + throw new \Exception('This account has been deactivated.'); + } + }); } // Add the following method to the User model: last_login_at & login_count features @@ -127,4 +135,14 @@ public function updateLoginTracking() $this->increment('login_count'); $this->save(); } + + // Add the following method to the User model: delete method + public function delete() + { + if ($this->id === auth()->id()) { + throw new \Exception('You cannot delete your own account.'); + } + + return parent::delete(); + } } diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php index b5f58d9..1c95536 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -130,7 +130,11 @@ ->assertTableActionEnabled(DeleteAction::class, $user) ->callTableAction(DeleteAction::class, $user); - $this->assertModelMissing($user); + // $this->assertModelMissing($user); + + // replaced the above line with the following because this properly checks that the user is soft deleted instead of completely removed from the database. + + $this->assertSoftDeleted('users', ['id' => $user->id]); }); test('authed user cannot delete himself', function () { diff --git a/tests/Feature/UserTrashRestoreTest.php b/tests/Feature/UserTrashRestoreTest.php new file mode 100644 index 0000000..4c1f72b --- /dev/null +++ b/tests/Feature/UserTrashRestoreTest.php @@ -0,0 +1,49 @@ +create(); + $user = User::factory()->create(); + + // Simulate admin permission + Auth::login($admin); + + $user->delete(); + + expect($user->fresh()->trashed())->toBeTrue(); +}); + +test('user cannot trash himself', function () { + $user = User::factory()->create(); + + Auth::login($user); + + $this->expectException(\Exception::class); + $user->delete(); +}); + +test('authorized user can restore a trashed user', function () { + $admin = User::factory()->create(); + $user = User::factory()->create(); + $user->delete(); // Move to trash + + Auth::login($admin); + + $user->restore(); // Restore user + + expect($user->fresh()->trashed())->toBeFalse(); +}); + +test('trashed user cannot login', function () { + $user = User::factory()->create(); + $user->delete(); // Move to trash + + $attempt = Auth::attempt(['email' => $user->email, 'password' => 'password']); + + expect($attempt)->toBeFalse(); +}); From 1bb7d264b0a44556ba12fcb85281d656764ff757 Mon Sep 17 00:00:00 2001 From: sinach Date: Sat, 15 Mar 2025 15:48:52 +0100 Subject: [PATCH 3/4] fix: made code fixes to the corrections provided for login tracking --- .lando.yml => .lando.dist.yml | 0 database/factories/UserFactory.php | 1 + src/EclipseServiceProvider.php | 12 ++++++++--- src/Models/User.php | 19 +++++++++-------- tests/Feature/LoginTrackingTest.php | 32 ++--------------------------- 5 files changed, 23 insertions(+), 41 deletions(-) rename .lando.yml => .lando.dist.yml (100%) diff --git a/.lando.yml b/.lando.dist.yml similarity index 100% rename from .lando.yml rename to .lando.dist.yml diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index c1d68be..9cf74b3 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -38,6 +38,7 @@ public function definition(): array 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'remember_token' => Str::random(10), + 'login_count' => 0, ]; } diff --git a/src/EclipseServiceProvider.php b/src/EclipseServiceProvider.php index 91fe09a..51f0f72 100644 --- a/src/EclipseServiceProvider.php +++ b/src/EclipseServiceProvider.php @@ -5,8 +5,11 @@ use Eclipse\Core\Console\Commands\ClearCommand; use Eclipse\Core\Console\Commands\DeployCommand; use Eclipse\Core\Console\Commands\PostComposerUpdate; +use Eclipse\Core\Models\User; use Eclipse\Core\Providers\AdminPanelProvider; use Eclipse\Core\Providers\TelescopeServiceProvider; +use Illuminate\Auth\Events\Login; +use Illuminate\Support\Facades\Event; use Spatie\LaravelPackageTools\Package; use Spatie\LaravelPackageTools\PackageServiceProvider; @@ -27,9 +30,6 @@ public function configurePackage(Package $package): void 'permission', 'telescope', ]) - ->hasMigrations([ - 'add_login_tracking_to_users', - ]) ->discoversMigrations() ->runsMigrations() ->hasTranslations(); @@ -41,6 +41,12 @@ public function register() require_once __DIR__.'/Helpers/helpers.php'; + Event::listen(Login::class, function ($event) { + if ($event->user instanceof User) { + $event->user->updateLoginTracking(); + } + }); + $this->app->register(AdminPanelProvider::class); if ($this->app->environment('local') && class_exists(\Laravel\Telescope\TelescopeServiceProvider::class)) { diff --git a/src/Models/User.php b/src/Models/User.php index 5bca82e..0af1eda 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -115,12 +115,6 @@ protected static function booted() $user->name = trim("$user->first_name $user->last_name"); }); - static::creating(function (self $user) { - if (is_null($user->login_count)) { - $user->login_count = 0; - } - }); - static::retrieved(function (self $user) { if ($user->trashed() && request()->routeIs('login')) { throw new \Exception('This account has been deactivated.'); @@ -128,7 +122,11 @@ protected static function booted() }); } - // Add the following method to the User model: last_login_at & login_count features + /** + * Update the user's last login timestamp and increment login count. + * + * @return void + */ public function updateLoginTracking() { $this->last_login_at = now(); @@ -136,7 +134,12 @@ public function updateLoginTracking() $this->save(); } - // Add the following method to the User model: delete method + /** + * Delete the user account, preventing self-deletion. + * + * @throws \Exception If the user attempts to delete their own account. + * @return bool|null + */ public function delete() { if ($this->id === auth()->id()) { diff --git a/tests/Feature/LoginTrackingTest.php b/tests/Feature/LoginTrackingTest.php index 60d916b..54cdab4 100644 --- a/tests/Feature/LoginTrackingTest.php +++ b/tests/Feature/LoginTrackingTest.php @@ -10,7 +10,7 @@ $user = User::factory()->create(); expect($user->last_login_at)->toBeNull(); - expect($user->login_count ?? 0)->toBe(0); + expect($user->login_count)->toBe(0); }); test('user login updates last login timestamp and increments count', function () { @@ -21,40 +21,12 @@ // Simulate login Auth::login($user); - $user->updateLoginTracking(); - $user->refresh(); + $user->refresh(); // Reload from DB to reflect changes expect($user->last_login_at)->not->toBeNull(); expect($user->login_count)->toBe(1); }); -test('multiple logins correctly increment login count', function () { - $user = User::factory()->create([ - 'login_count' => 2, // User has logged in twice before - ]); - - Auth::login($user); - $user->updateLoginTracking(); - $user->refresh(); - - expect($user->login_count)->toBe(3); // Should increase by 1 -}); - -test('login tracking does not reset on logout', function () { - $user = User::factory()->create([ - 'last_login_at' => now()->subDays(1), - 'login_count' => 5, - ]); - - Auth::login($user); - $user->updateLoginTracking(); - Auth::logout(); - $user->refresh(); - - expect($user->last_login_at)->not->toBeNull(); - expect($user->login_count)->toBe(6); // Login count should remain after logout -}); - test('guest users do not have login tracking data', function () { $this->get('/admin')->assertRedirect('admin/login'); From 1f4e6ecabd3bde1c4d818de43461ab207e5bcc75 Mon Sep 17 00:00:00 2001 From: sinach Date: Mon, 17 Mar 2025 09:09:52 +0100 Subject: [PATCH 4/4] fix: removed everything related to user trash and restore feature --- ...03_15_000001_add_soft_deletes_to_users.php | 21 -------- src/Filament/Resources/UserResource.php | 15 +----- src/Models/User.php | 25 +--------- .../Filament/Resources/UserResourceTest.php | 6 +-- tests/Feature/UserTrashRestoreTest.php | 49 ------------------- 5 files changed, 4 insertions(+), 112 deletions(-) delete mode 100644 database/migrations/2025_03_15_000001_add_soft_deletes_to_users.php delete mode 100644 tests/Feature/UserTrashRestoreTest.php 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 deleted file mode 100644 index 10ba119..0000000 --- a/database/migrations/2025_03_15_000001_add_soft_deletes_to_users.php +++ /dev/null @@ -1,21 +0,0 @@ -softDeletes(); // ✅ Adds `deleted_at` column - }); - } - - public function down() - { - Schema::table('users', function (Blueprint $table) { - $table->dropSoftDeletes(); - }); - } -}; diff --git a/src/Filament/Resources/UserResource.php b/src/Filament/Resources/UserResource.php index 82b4630..6887e79 100644 --- a/src/Filament/Resources/UserResource.php +++ b/src/Filament/Resources/UserResource.php @@ -32,8 +32,6 @@ class UserResource extends Resource implements HasShieldPermissions protected static ?string $recordTitleAttribute = 'first_name'; - protected static bool $softDeletes = true; - public static function form(Form $form): Form { return $form->schema([ @@ -162,9 +160,6 @@ public static function table(Table $table): Table TextConstraint::make('login_count') ->label('Total Logins'), ]), - - // added trash filter - Tables\Filters\TrashedFilter::make() ]; return $table @@ -174,14 +169,8 @@ public static function table(Table $table): Table Tables\Actions\ActionGroup::make([ Tables\Actions\ViewAction::make(), Tables\Actions\EditAction::make(), - Tables\Actions\DeleteAction::make()->disabled(fn (User $user) => $user->id === auth()->user()->id) - ->visible(fn (User $user) => $user->id !== auth()->user()->id) - ->authorize(fn () => auth()->user()->can('delete', User::class)) - ->requiresConfirmation(), - Tables\Actions\RestoreAction::make() - ->visible(fn (User $user) => $user->trashed()) - ->authorize(fn () => auth()->user()->can('restore', User::class)) - ->requiresConfirmation(), + Tables\Actions\DeleteAction::make() + ->disabled(fn (User $user) => $user->id === auth()->user()->id), ]), ]) ->bulkActions([ diff --git a/src/Models/User.php b/src/Models/User.php index 0af1eda..b223ee3 100644 --- a/src/Models/User.php +++ b/src/Models/User.php @@ -15,7 +15,6 @@ use Spatie\MediaLibrary\HasMedia; use Spatie\MediaLibrary\InteractsWithMedia; use Spatie\Permission\Traits\HasRoles; -use Illuminate\Database\Eloquent\SoftDeletes; /** * @property int $id @@ -31,10 +30,9 @@ */ class User extends Authenticatable implements FilamentUser, HasAvatar, HasMedia, HasTenants { - use HasFactory, HasRoles, InteractsWithMedia, Notifiable, SoftDeletes; + use HasFactory, HasRoles, InteractsWithMedia, Notifiable; protected $table = 'users'; - protected $dates = ['deleted_at']; /** * The attributes that are mass assignable. @@ -114,12 +112,6 @@ 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() && request()->routeIs('login')) { - throw new \Exception('This account has been deactivated.'); - } - }); } /** @@ -133,19 +125,4 @@ 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() - { - if ($this->id === auth()->id()) { - throw new \Exception('You cannot delete your own account.'); - } - - return parent::delete(); - } } diff --git a/tests/Feature/Filament/Resources/UserResourceTest.php b/tests/Feature/Filament/Resources/UserResourceTest.php index 1c95536..b5f58d9 100644 --- a/tests/Feature/Filament/Resources/UserResourceTest.php +++ b/tests/Feature/Filament/Resources/UserResourceTest.php @@ -130,11 +130,7 @@ ->assertTableActionEnabled(DeleteAction::class, $user) ->callTableAction(DeleteAction::class, $user); - // $this->assertModelMissing($user); - - // replaced the above line with the following because this properly checks that the user is soft deleted instead of completely removed from the database. - - $this->assertSoftDeleted('users', ['id' => $user->id]); + $this->assertModelMissing($user); }); test('authed user cannot delete himself', function () { diff --git a/tests/Feature/UserTrashRestoreTest.php b/tests/Feature/UserTrashRestoreTest.php deleted file mode 100644 index 4c1f72b..0000000 --- a/tests/Feature/UserTrashRestoreTest.php +++ /dev/null @@ -1,49 +0,0 @@ -create(); - $user = User::factory()->create(); - - // Simulate admin permission - Auth::login($admin); - - $user->delete(); - - expect($user->fresh()->trashed())->toBeTrue(); -}); - -test('user cannot trash himself', function () { - $user = User::factory()->create(); - - Auth::login($user); - - $this->expectException(\Exception::class); - $user->delete(); -}); - -test('authorized user can restore a trashed user', function () { - $admin = User::factory()->create(); - $user = User::factory()->create(); - $user->delete(); // Move to trash - - Auth::login($admin); - - $user->restore(); // Restore user - - expect($user->fresh()->trashed())->toBeFalse(); -}); - -test('trashed user cannot login', function () { - $user = User::factory()->create(); - $user->delete(); // Move to trash - - $attempt = Auth::attempt(['email' => $user->email, 'password' => 'password']); - - expect($attempt)->toBeFalse(); -});