feat: usr roles and soft deletes and filament
This commit is contained in:
parent
417a1bc53c
commit
ad17c55564
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
Loading…
Reference in New Issue