feat: usr roles and soft deletes and filament
Laravel CI-CD / Tests Unitaires (push) Failing after 29s Details
Laravel CI-CD / Deploy with Kamal (push) Has been skipped Details

This commit is contained in:
Leon Morival 2026-05-22 10:06:47 +02:00
parent 417a1bc53c
commit ad17c55564
16 changed files with 280 additions and 13 deletions

View File

@ -0,0 +1,47 @@
<?php
namespace App\Console\Commands;
use App\Enums\UserRole;
use App\Models\User;
use Illuminate\Console\Command;
class ChangeUserRole extends Command
{
protected $signature = 'user:change-role
{userId : The ID of the user}
{role : The new role: user, admin, or moderator}';
protected $description = 'Change a user role by user ID.';
public function handle(): int
{
$role = UserRole::tryFrom((string) $this->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;
}
}

31
app/Enums/UserRole.php Normal file
View File

@ -0,0 +1,31 @@
<?php
namespace App\Enums;
use Filament\Support\Contracts\HasColor;
use Filament\Support\Contracts\HasLabel;
enum UserRole: string implements HasColor, HasLabel
{
case USER = 'user';
case ADMIN = 'admin';
case MODERATOR = 'moderator';
public function getLabel(): string
{
return match ($this) {
self::USER => __('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',
};
}
}

View File

@ -4,6 +4,7 @@ namespace App\Filament\Resources\Users\Schemas;
use App\Enums\PacePreference; use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel; use App\Enums\PhysicalActivityLevel;
use App\Enums\UserRole;
use App\Enums\UserSex; use App\Enums\UserSex;
use App\Enums\WeightGoal; use App\Enums\WeightGoal;
use Filament\Forms\Components\DatePicker; use Filament\Forms\Components\DatePicker;
@ -40,6 +41,11 @@ class UserForm
->options(self::localeOptions()) ->options(self::localeOptions())
->default('en') ->default('en')
->required(), ->required(),
Select::make('role')
->label(__('admin.users.fields.role'))
->options(UserRole::class)
->default(UserRole::USER->value)
->required(),
FileUpload::make('avatar_url') FileUpload::make('avatar_url')
->label(__('admin.users.fields.avatar')) ->label(__('admin.users.fields.avatar'))
->image() ->image()

View File

@ -24,6 +24,9 @@ class UserInfolist
TextEntry::make('email') TextEntry::make('email')
->label(__('admin.users.fields.email')) ->label(__('admin.users.fields.email'))
->copyable(), ->copyable(),
TextEntry::make('role')
->label(__('admin.users.fields.role'))
->badge(),
TextEntry::make('email_verified_at') TextEntry::make('email_verified_at')
->label(__('admin.users.fields.email_verified_at')) ->label(__('admin.users.fields.email_verified_at'))
->dateTime() ->dateTime()

View File

@ -4,6 +4,7 @@ namespace App\Filament\Resources\Users\Tables;
use App\Enums\PacePreference; use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel; use App\Enums\PhysicalActivityLevel;
use App\Enums\UserRole;
use App\Enums\UserSex; use App\Enums\UserSex;
use App\Enums\WeightGoal; use App\Enums\WeightGoal;
use Filament\Actions\BulkActionGroup; use Filament\Actions\BulkActionGroup;
@ -33,6 +34,10 @@ class UsersTable
TextColumn::make('email') TextColumn::make('email')
->label(__('admin.users.fields.email')) ->label(__('admin.users.fields.email'))
->searchable(), ->searchable(),
TextColumn::make('role')
->label(__('admin.users.fields.role'))
->badge()
->sortable(),
TextColumn::make('email_verified_at') TextColumn::make('email_verified_at')
->label(__('admin.users.fields.email_verified_at')) ->label(__('admin.users.fields.email_verified_at'))
->dateTime() ->dateTime()
@ -118,6 +123,9 @@ class UsersTable
->toggleable(isToggledHiddenByDefault: true), ->toggleable(isToggledHiddenByDefault: true),
]) ])
->filters([ ->filters([
SelectFilter::make('role')
->label(__('admin.users.fields.role'))
->options(UserRole::class),
SelectFilter::make('sex') SelectFilter::make('sex')
->label(__('admin.users.fields.sex')) ->label(__('admin.users.fields.sex'))
->options(UserSex::class), ->options(UserSex::class),

View File

@ -16,6 +16,7 @@ class UserResource extends JsonResource
public function toArray(Request $request): array public function toArray(Request $request): array
{ {
return [ return [
'id' => $this->id, // à voir si suppression par la suite
'name' => $this->name, 'name' => $this->name,
'email' => $this->email, 'email' => $this->email,
'locale' => $this->locale, 'locale' => $this->locale,
@ -24,6 +25,7 @@ class UserResource extends JsonResource
'height' => $this->height, 'height' => $this->height,
'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null, 'avatarUrl' => $this->avatar_url ? asset(Storage::url($this->avatar_url)) : null,
'bio' => $this->bio, 'bio' => $this->bio,
'role' => $this->role,
'accountVerified' => $this->account_verified_at !== null, 'accountVerified' => $this->account_verified_at !== null,
'physicalActivityLevel' => $this->physical_activity_level, 'physicalActivityLevel' => $this->physical_activity_level,
'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(), 'physicalActivityLevelLabel' => $this->physical_activity_level?->getLabel(),

View File

@ -4,6 +4,7 @@ namespace App\Models;
use App\Enums\PacePreference; use App\Enums\PacePreference;
use App\Enums\PhysicalActivityLevel; use App\Enums\PhysicalActivityLevel;
use App\Enums\UserRole;
use App\Enums\UserSex; use App\Enums\UserSex;
use App\Enums\WeightGoal; use App\Enums\WeightGoal;
use Filament\Models\Contracts\FilamentUser; use Filament\Models\Contracts\FilamentUser;
@ -12,9 +13,9 @@ use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Contracts\Translation\HasLocalePreference; use Illuminate\Contracts\Translation\HasLocalePreference;
use Illuminate\Database\Eloquent\Concerns\HasUlids; use Illuminate\Database\Eloquent\Concerns\HasUlids;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\Relations\HasOne; use Illuminate\Database\Eloquent\Relations\HasOne;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable; use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens; use Laravel\Sanctum\HasApiTokens;
@ -22,7 +23,7 @@ use Laravel\Sanctum\HasApiTokens;
class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail class User extends Authenticatable implements FilamentUser, HasLocalePreference, MustVerifyEmail
{ {
/** @use HasFactory<\Database\Factories\UserFactory> */ /** @use HasFactory<\Database\Factories\UserFactory> */
use HasApiTokens, HasFactory, HasUlids, Notifiable; use HasApiTokens, HasFactory, HasUlids, Notifiable, SoftDeletes;
/** /**
* The attributes that are mass assignable. * The attributes that are mass assignable.
@ -33,6 +34,7 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
'name', 'name',
'email', 'email',
'locale', 'locale',
'role',
'email_verified_at', 'email_verified_at',
'password', 'password',
'avatar_url', 'avatar_url',
@ -79,6 +81,7 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
'weight_goal' => WeightGoal::class, 'weight_goal' => WeightGoal::class,
'pace_preference' => PacePreference::class, 'pace_preference' => PacePreference::class,
'sex' => UserSex::class, 'sex' => UserSex::class,
'role' => UserRole::class,
'date_of_birth' => 'immutable_date', 'date_of_birth' => 'immutable_date',
]; ];
} }
@ -103,12 +106,6 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
return $this->hasOne(StravaConnection::class); 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 public function routeNotificationForExpoPush(): array
{ {
return $this->deviceTokens() return $this->deviceTokens()
@ -122,15 +119,13 @@ class User extends Authenticatable implements FilamentUser, HasLocalePreference,
return $this->locale ?: 'en'; return $this->locale ?: 'en';
} }
public function isAdmin() public function isAdmin(): bool
{ {
return true; return $this->role === UserRole::ADMIN;
// return $this->role === 'admin';
} }
public function canAccessPanel(Panel $panel): bool public function canAccessPanel(Panel $panel): bool
{ {
return $this->role === UserRole::ADMIN || $this->role === UserRole::MODERATOR;
return true;
} }
} }

View File

@ -58,6 +58,7 @@ env:
APP_LOCALE: fr APP_LOCALE: fr
APP_ENV: production APP_ENV: production
APP_DEBUG: "false" APP_DEBUG: "false"
APP_NAME: "Bowly"
APP_URL: https://bemeal.leonmorival.com APP_URL: https://bemeal.leonmorival.com
SERVER_NAME: ":80" SERVER_NAME: ":80"
APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime" APP_RUNTIME: "Laravel\\FrankenPHP\\Runtime"

View File

@ -2,6 +2,7 @@
namespace Database\Factories; namespace Database\Factories;
use App\Enums\UserRole;
use Illuminate\Database\Eloquent\Factories\Factory; use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str; use Illuminate\Support\Str;
@ -27,6 +28,7 @@ class UserFactory extends Factory
'name' => fake()->name(), 'name' => fake()->name(),
'email' => fake()->unique()->safeEmail(), 'email' => fake()->unique()->safeEmail(),
'locale' => 'en', 'locale' => 'en',
'role' => UserRole::USER,
'email_verified_at' => now(), 'email_verified_at' => now(),
'password' => static::$password ??= Hash::make('password'), 'password' => static::$password ??= Hash::make('password'),
'avatar_url' => null, 'avatar_url' => null,

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('role')->default('user');
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('role');
});
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->softDeletesTz();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropSoftDeletesTz();
});
}
};

View File

@ -19,6 +19,7 @@ return [
'name' => 'Name', 'name' => 'Name',
'email' => 'Email address', 'email' => 'Email address',
'locale' => 'Language', 'locale' => 'Language',
'role' => 'Role',
'email_verified_at' => 'Email verified at', 'email_verified_at' => 'Email verified at',
'account_verified_at' => 'Account verified at', 'account_verified_at' => 'Account verified at',
'avatar' => 'Avatar', 'avatar' => 'Avatar',

View File

@ -19,6 +19,11 @@ return [
'other' => 'Other', 'other' => 'Other',
'unknown' => 'Not specified', 'unknown' => 'Not specified',
], ],
'user_role' => [
'user' => 'User',
'admin' => 'Administrator',
'moderator' => 'Moderator',
],
'weight_goal' => [ 'weight_goal' => [
'lose_weight' => 'Lose weight', 'lose_weight' => 'Lose weight',
'maintain_weight' => 'Maintain weight', 'maintain_weight' => 'Maintain weight',

View File

@ -19,6 +19,7 @@ return [
'name' => 'Nom', 'name' => 'Nom',
'email' => 'Adresse email', 'email' => 'Adresse email',
'locale' => 'Langue', 'locale' => 'Langue',
'role' => 'Rôle',
'email_verified_at' => 'Email vérifié le', 'email_verified_at' => 'Email vérifié le',
'account_verified_at' => 'Compte vérifié le', 'account_verified_at' => 'Compte vérifié le',
'avatar' => 'Avatar', 'avatar' => 'Avatar',

View File

@ -19,6 +19,11 @@ return [
'other' => 'Autre', 'other' => 'Autre',
'unknown' => 'Non renseigné', 'unknown' => 'Non renseigné',
], ],
'user_role' => [
'user' => 'Utilisateur',
'admin' => 'Administrateur',
'moderator' => 'Modérateur',
],
'weight_goal' => [ 'weight_goal' => [
'lose_weight' => 'Perdre du poids', 'lose_weight' => 'Perdre du poids',
'maintain_weight' => 'Maintenir le poids', 'maintain_weight' => 'Maintenir le poids',

View File

@ -0,0 +1,104 @@
<?php
use App\Enums\UserRole;
use App\Filament\Resources\Users\Pages\EditUser;
use App\Filament\Resources\Users\Pages\ListUsers;
use App\Filament\Resources\Users\Pages\ViewUser;
use App\Models\User;
use Filament\Facades\Filament;
use Filament\Forms\Components\Select;
use Filament\Infolists\Components\TextEntry;
use Filament\Tables\Columns\TextColumn;
use Filament\Tables\Filters\SelectFilter;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
app()->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);
});