From ad17c5556470d5fae5c21ade9cd7ba6032466171 Mon Sep 17 00:00:00 2001 From: Leon Morival Date: Fri, 22 May 2026 10:06:47 +0200 Subject: [PATCH] feat: usr roles and soft deletes and filament --- app/Console/Commands/ChangeUserRole.php | 47 ++++++++ app/Enums/UserRole.php | 31 ++++++ .../Resources/Users/Schemas/UserForm.php | 6 + .../Resources/Users/Schemas/UserInfolist.php | 3 + .../Resources/Users/Tables/UsersTable.php | 8 ++ app/Http/Resources/UserResource.php | 2 + app/Models/User.php | 21 ++-- config/deploy.yml | 1 + database/factories/UserFactory.php | 2 + ...6_05_22_073307_add_role_to_users_table.php | 28 +++++ ...074902_add_soft_deletes_to_users_table.php | 28 +++++ lang/en/admin.php | 1 + lang/en/enums.php | 5 + lang/fr/admin.php | 1 + lang/fr/enums.php | 5 + tests/Feature/UserFilamentRoleTest.php | 104 ++++++++++++++++++ 16 files changed, 280 insertions(+), 13 deletions(-) create mode 100644 app/Console/Commands/ChangeUserRole.php create mode 100644 app/Enums/UserRole.php create mode 100644 database/migrations/2026_05_22_073307_add_role_to_users_table.php create mode 100644 database/migrations/2026_05_22_074902_add_soft_deletes_to_users_table.php create mode 100644 tests/Feature/UserFilamentRoleTest.php diff --git a/app/Console/Commands/ChangeUserRole.php b/app/Console/Commands/ChangeUserRole.php new file mode 100644 index 0000000..7e753f6 --- /dev/null +++ b/app/Console/Commands/ChangeUserRole.php @@ -0,0 +1,47 @@ +argument('role')); + + if ($role === null) { + $availableRoles = collect(UserRole::cases()) + ->map(fn (UserRole $role): string => $role->value) + ->implode(', '); + + $this->error("Invalid role. Available roles: {$availableRoles}."); + + return self::FAILURE; + } + + $user = User::query()->find((string) $this->argument('userId')); + + if ($user === null) { + $this->error('User not found.'); + + return self::FAILURE; + } + + $user->forceFill([ + 'role' => $role, + ])->save(); + + $this->info("User {$user->getKey()} role changed to {$role->value}."); + + return self::SUCCESS; + } +} diff --git a/app/Enums/UserRole.php b/app/Enums/UserRole.php new file mode 100644 index 0000000..fe80ed9 --- /dev/null +++ b/app/Enums/UserRole.php @@ -0,0 +1,31 @@ + __('enums.user_role.user'), + self::ADMIN => __('enums.user_role.admin'), + self::MODERATOR => __('enums.user_role.moderator'), + }; + } + + public function getColor(): string + { + return match ($this) { + self::USER => 'gray', + self::ADMIN => 'danger', + self::MODERATOR => 'warning', + }; + } +} diff --git a/app/Filament/Resources/Users/Schemas/UserForm.php b/app/Filament/Resources/Users/Schemas/UserForm.php index 6160188..2048456 100644 --- a/app/Filament/Resources/Users/Schemas/UserForm.php +++ b/app/Filament/Resources/Users/Schemas/UserForm.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources\Users\Schemas; use App\Enums\PacePreference; use App\Enums\PhysicalActivityLevel; +use App\Enums\UserRole; use App\Enums\UserSex; use App\Enums\WeightGoal; use Filament\Forms\Components\DatePicker; @@ -40,6 +41,11 @@ class UserForm ->options(self::localeOptions()) ->default('en') ->required(), + Select::make('role') + ->label(__('admin.users.fields.role')) + ->options(UserRole::class) + ->default(UserRole::USER->value) + ->required(), FileUpload::make('avatar_url') ->label(__('admin.users.fields.avatar')) ->image() diff --git a/app/Filament/Resources/Users/Schemas/UserInfolist.php b/app/Filament/Resources/Users/Schemas/UserInfolist.php index b6f823e..aa23b1f 100644 --- a/app/Filament/Resources/Users/Schemas/UserInfolist.php +++ b/app/Filament/Resources/Users/Schemas/UserInfolist.php @@ -24,6 +24,9 @@ class UserInfolist TextEntry::make('email') ->label(__('admin.users.fields.email')) ->copyable(), + TextEntry::make('role') + ->label(__('admin.users.fields.role')) + ->badge(), TextEntry::make('email_verified_at') ->label(__('admin.users.fields.email_verified_at')) ->dateTime() diff --git a/app/Filament/Resources/Users/Tables/UsersTable.php b/app/Filament/Resources/Users/Tables/UsersTable.php index 1739680..1332298 100644 --- a/app/Filament/Resources/Users/Tables/UsersTable.php +++ b/app/Filament/Resources/Users/Tables/UsersTable.php @@ -4,6 +4,7 @@ namespace App\Filament\Resources\Users\Tables; use App\Enums\PacePreference; use App\Enums\PhysicalActivityLevel; +use App\Enums\UserRole; use App\Enums\UserSex; use App\Enums\WeightGoal; use Filament\Actions\BulkActionGroup; @@ -33,6 +34,10 @@ class UsersTable TextColumn::make('email') ->label(__('admin.users.fields.email')) ->searchable(), + TextColumn::make('role') + ->label(__('admin.users.fields.role')) + ->badge() + ->sortable(), TextColumn::make('email_verified_at') ->label(__('admin.users.fields.email_verified_at')) ->dateTime() @@ -118,6 +123,9 @@ class UsersTable ->toggleable(isToggledHiddenByDefault: true), ]) ->filters([ + SelectFilter::make('role') + ->label(__('admin.users.fields.role')) + ->options(UserRole::class), SelectFilter::make('sex') ->label(__('admin.users.fields.sex')) ->options(UserSex::class), diff --git a/app/Http/Resources/UserResource.php b/app/Http/Resources/UserResource.php index 7a5ed4d..ac103ba 100644 --- a/app/Http/Resources/UserResource.php +++ b/app/Http/Resources/UserResource.php @@ -16,6 +16,7 @@ class UserResource extends JsonResource public function toArray(Request $request): array { return [ + 'id' => $this->id, // à voir si suppression par la suite 'name' => $this->name, 'email' => $this->email, 'locale' => $this->locale, @@ -24,6 +25,7 @@ class UserResource extends JsonResource 'height' => $this->height, 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, 'bio' => $this->bio, + 'role' => $this->role, 'accountVerified' => $this->account_verified_at !== null, 'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), diff --git a/app/Models/User.php b/app/Models/User.php index 567f2af..7c49fe6 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -4,6 +4,7 @@ namespace App\Models; use App\Enums\PacePreference; use App\Enums\PhysicalActivityLevel; +use App\Enums\UserRole; use App\Enums\UserSex; use App\Enums\WeightGoal; use Filament\Models\Contracts\FilamentUser; @@ -12,9 +13,9 @@ use Illuminate\Contracts\Auth\MustVerifyEmail; use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Factories\HasFactory; -use Illuminate\Database\Eloquent\Relations\BelongsToMany; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasOne; +use Illuminate\Database\Eloquent\SoftDeletes; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Sanctum\HasApiTokens; @@ -22,7 +23,7 @@ use Laravel\Sanctum\HasApiTokens; class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail { /** @use HasFactory<\Database\Factories\UserFactory> */ - use HasApiTokens, HasFactory, HasUlids, Notifiable; + use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes; /** * The attributes that are mass assignable. @@ -33,6 +34,7 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, 'name', 'email', 'locale', + 'role', 'email_verified_at', 'password', 'avatar_url', @@ -79,6 +81,7 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, 'weight_goal' => WeightGoal::class, 'pace_preference' => PacePreference::class, 'sex' => UserSex::class, + 'role' => UserRole::class, 'date_of_birth' => 'immutable_date', ]; } @@ -103,12 +106,6 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, return $this->hasOne(StravaConnection::class); } - public function likedMealPosts(): BelongsToMany - { - return $this->belongsToMany(MealPosts::class, 'meal_post_likes', 'user_id', 'meal_posts_id') - ->withTimestamps(); - } - public function routeNotificationForExpoPush(): array { return $this->deviceTokens() @@ -122,15 +119,13 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference, return $this->locale ?: 'en'; } - public function isAdmin() + public function isAdmin(): bool { - return true; - // return $this->role === 'admin'; + return $this->role === UserRole::ADMIN; } public function canAccessPanel(Panel $panel): bool { - - return true; + return $this->role === UserRole::ADMIN || $this->role === UserRole::MODERATOR; } } diff --git a/config/deploy.yml b/config/deploy.yml index 12b9fc6..956239b 100644 --- a/config/deploy.yml +++ b/config/deploy.yml @@ -58,6 +58,7 @@ env: APP_LOCALE: fr APP_ENV: production APP_DEBUG: "false" + APP_NAME: "Bowly" APP_URL: https://bemeal.leonmorival.com SERVER_NAME: ":80" APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" diff --git a/database/factories/UserFactory.php b/database/factories/UserFactory.php index e577b4a..0f4e460 100644 --- a/database/factories/UserFactory.php +++ b/database/factories/UserFactory.php @@ -2,6 +2,7 @@ namespace Database\Factories; +use App\Enums\UserRole; use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -27,6 +28,7 @@ class UserFactory extends Factory 'name' => fake()->name(), 'email' => fake()->unique()->safeEmail(), 'locale' => 'en', + 'role' => UserRole::USER, 'email_verified_at' => now(), 'password' => static::$password ??= Hash::make('password'), 'avatar_url' => null, diff --git a/database/migrations/2026_05_22_073307_add_role_to_users_table.php b/database/migrations/2026_05_22_073307_add_role_to_users_table.php new file mode 100644 index 0000000..5538d45 --- /dev/null +++ b/database/migrations/2026_05_22_073307_add_role_to_users_table.php @@ -0,0 +1,28 @@ +string('role')->default('user'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropColumn('role'); + }); + } +}; diff --git a/database/migrations/2026_05_22_074902_add_soft_deletes_to_users_table.php b/database/migrations/2026_05_22_074902_add_soft_deletes_to_users_table.php new file mode 100644 index 0000000..1eefa36 --- /dev/null +++ b/database/migrations/2026_05_22_074902_add_soft_deletes_to_users_table.php @@ -0,0 +1,28 @@ +softDeletesTz(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::table('users', function (Blueprint $table) { + $table->dropSoftDeletesTz(); + }); + } +}; diff --git a/lang/en/admin.php b/lang/en/admin.php index e6cde89..d5b2b05 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -19,6 +19,7 @@ return [ 'name' => 'Name', 'email' => 'Email address', 'locale' => 'Language', + 'role' => 'Role', 'email_verified_at' => 'Email verified at', 'account_verified_at' => 'Account verified at', 'avatar' => 'Avatar', diff --git a/lang/en/enums.php b/lang/en/enums.php index 99fc971..1fa44a7 100644 --- a/lang/en/enums.php +++ b/lang/en/enums.php @@ -19,6 +19,11 @@ return [ 'other' => 'Other', 'unknown' => 'Not specified', ], + 'user_role' => [ + 'user' => 'User', + 'admin' => 'Administrator', + 'moderator' => 'Moderator', + ], 'weight_goal' => [ 'lose_weight' => 'Lose weight', 'maintain_weight' => 'Maintain weight', diff --git a/lang/fr/admin.php b/lang/fr/admin.php index af4d9bc..9480f96 100644 --- a/lang/fr/admin.php +++ b/lang/fr/admin.php @@ -19,6 +19,7 @@ return [ 'name' => 'Nom', 'email' => 'Adresse email', 'locale' => 'Langue', + 'role' => 'Rôle', 'email_verified_at' => 'Email vérifié le', 'account_verified_at' => 'Compte vérifié le', 'avatar' => 'Avatar', diff --git a/lang/fr/enums.php b/lang/fr/enums.php index e229590..f0e4b18 100644 --- a/lang/fr/enums.php +++ b/lang/fr/enums.php @@ -19,6 +19,11 @@ return [ 'other' => 'Autre', 'unknown' => 'Non renseigné', ], + 'user_role' => [ + 'user' => 'Utilisateur', + 'admin' => 'Administrateur', + 'moderator' => 'Modérateur', + ], 'weight_goal' => [ 'lose_weight' => 'Perdre du poids', 'maintain_weight' => 'Maintenir le poids', diff --git a/tests/Feature/UserFilamentRoleTest.php b/tests/Feature/UserFilamentRoleTest.php new file mode 100644 index 0000000..5c79a3a --- /dev/null +++ b/tests/Feature/UserFilamentRoleTest.php @@ -0,0 +1,104 @@ +setLocale('fr'); + + Filament::setCurrentPanel(Filament::getPanel('dashboard')); +}); + +it('exposes translated user roles in filament user screens', function () { + $admin = User::factory()->create([ + 'role' => UserRole::ADMIN, + ]); + $user = User::factory()->create([ + 'role' => UserRole::MODERATOR, + ]); + + $this->actingAs($admin); + + $expectedOptions = [ + 'user' => 'Utilisateur', + 'admin' => 'Administrateur', + 'moderator' => 'Modérateur', + ]; + + Livewire::test(EditUser::class, ['record' => $user->getKey()]) + ->assertFormFieldExists( + 'role', + null, + fn (Select $field): bool => $field->getLabel() === 'Rôle' + && $field->getOptions() === $expectedOptions, + ); + + Livewire::test(ViewUser::class, ['record' => $user->getKey()]) + ->assertSchemaComponentExists( + 'role', + 'infolist', + fn (TextEntry $entry): bool => $entry->getLabel() === 'Rôle', + ); + + Livewire::test(ListUsers::class) + ->assertTableColumnExists( + 'role', + fn (TextColumn $column): bool => $column->getLabel() === 'Rôle', + ) + ->assertTableFilterExists( + 'role', + fn (SelectFilter $filter): bool => $filter->getLabel() === 'Rôle' + && $filter->getOptions() === $expectedOptions, + ); +}); + +it('changes a user role by id with the artisan command', function () { + $user = User::factory()->create([ + 'role' => UserRole::USER, + ]); + + $this->artisan('user:change-role', [ + 'userId' => $user->getKey(), + 'role' => UserRole::ADMIN->value, + ]) + ->expectsOutput("User {$user->getKey()} role changed to admin.") + ->assertExitCode(0); + + expect($user->fresh()->role)->toBe(UserRole::ADMIN); +}); + +it('rejects an invalid user role', function () { + $user = User::factory()->create([ + 'role' => UserRole::USER, + ]); + + $this->artisan('user:change-role', [ + 'userId' => $user->getKey(), + 'role' => 'owner', + ]) + ->expectsOutput('Invalid role. Available roles: user, admin, moderator.') + ->assertExitCode(1); + + expect($user->fresh()->role)->toBe(UserRole::USER); +}); + +it('rejects an unknown user id', function () { + $this->artisan('user:change-role', [ + 'userId' => '01J00000000000000000000000', + 'role' => UserRole::ADMIN->value, + ]) + ->expectsOutput('User not found.') + ->assertExitCode(1); +});