diff --git a/.gitignore b/.gitignore index da4d77b..92661ae 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,4 @@ cert.pem /.blueprint /.editorconfig /storage/media-library/* +/.run diff --git a/README.md b/README.md index 08e3ff4..0d8803c 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ docker-compose up -d php - [x] Recipe Manager - [x] Image uploads - [ ] Paginate Recipe Manager -- [ ] User Management +- [x] User Management ### Other - [ ] Customizable intro blurb and logo - [x] Recent recipes on homepage diff --git a/app/Enums/UserRoleEnum.php b/app/Enums/UserRoleEnum.php index f826bc0..c8f1f6b 100644 --- a/app/Enums/UserRoleEnum.php +++ b/app/Enums/UserRoleEnum.php @@ -2,8 +2,15 @@ namespace App\Enums; +use App\Traits\DefaultLabelsFromEnum; +use App\Traits\SelectOptionsFromEnum; + enum UserRoleEnum: string { + use DefaultLabelsFromEnum; + use SelectOptionsFromEnum; + + case MEMBER = ''; case ADMIN = 'admin'; case CONTRIBUTOR = 'contributor'; } diff --git a/app/Http/Controllers/Admin/UserController.php b/app/Http/Controllers/Admin/UserController.php new file mode 100644 index 0000000..6319cfe --- /dev/null +++ b/app/Http/Controllers/Admin/UserController.php @@ -0,0 +1,82 @@ +with([ + 'users' => fn () => User::withCount('recipes') + ->orderBy('id', 'desc') + ->get(), + 'roles' => UserRoleEnum::toSelectOptions(), + ]); + } + + public function store(Request $request): RedirectResponse + { + $validated = $request->validate([ + 'name' => ['required', 'max:140'], + 'email' => ['required', 'email', Rule::unique('users')], + 'password' => ['required', 'string', 'confirmed', 'min:8'], + 'role' => ['nullable', 'string', 'in:'.collect(UserRoleEnum::cases())->implode(fn ($r) => $r->value, ',')], + ]); + + $user = User::create([ + 'name' => $validated['name'], + 'email' => $validated['email'], + 'password' => bcrypt($validated['password']), + 'role' => UserRoleEnum::tryFrom($validated['role']), + ]); + + return redirect()->route('admin.users.index')->withBanner("Successfully created {$user->name}"); + } + + public function edit(User $user) + { + return Inertia::render('Admin/User/UserEdit')->with([ + 'user' => fn () => $user->makeVisible('id'), + 'roles' => fn () => UserRoleEnum::toSelectOptions(), + ]); + } + + public function update(User $user, Request $request) + { + $validated = $request->validate([ + 'name' => ['required', 'max:140'], + 'email' => ['required', 'email', Rule::unique('users')->ignore($user->id)], + 'role' => ['nullable', 'string', 'in:'.collect(UserRoleEnum::cases())->implode(fn ($r) => $r->value, ',')], + ]); + + $user->update($validated); + + return redirect()->back()->withBanner("Successfully updated {$user->name}"); + } + + public function create() + { + return Inertia::render('Admin/User/UserEdit')->with([ + 'user' => fn () => new User(), + 'roles' => fn () => UserRoleEnum::toSelectOptions(), + ]); + } + + public function destroy(User $user) + { + $name = $user->name; + $user->delete(); + + return redirect()->route('admin.users.index') + ->withBanner("Deleted {$name}", BannerTypeEnum::danger); + } +} diff --git a/app/Models/Recipe.php b/app/Models/Recipe.php index c5d86c9..33b511e 100644 --- a/app/Models/Recipe.php +++ b/app/Models/Recipe.php @@ -106,6 +106,9 @@ public function categories(): BelongsToMany return $this->belongsToMany(Category::class)->orderBy((new Category)->determineOrderColumnName()); } + /** + * @codeCoverageIgnore + */ public function user(): BelongsTo { return $this->belongsTo(User::class); diff --git a/app/Models/User.php b/app/Models/User.php index 0861988..bd84416 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -5,6 +5,7 @@ // use Illuminate\Contracts\Auth\MustVerifyEmail; use App\Enums\UserRoleEnum; use Illuminate\Database\Eloquent\Factories\HasFactory; +use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Foundation\Auth\User as Authenticatable; use Illuminate\Notifications\Notifiable; use Laravel\Fortify\TwoFactorAuthenticatable; @@ -66,6 +67,11 @@ protected function casts(): array ]; } + public function recipes(): HasMany + { + return $this->hasMany(Recipe::class); + } + public function isAdmin(): bool { return $this->role == UserRoleEnum::ADMIN; diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 49fcf45..108f607 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -18,9 +18,12 @@ public function register(): void { Model::preventLazyLoading(! app()->isProduction()); Model::preventAccessingMissingAttributes(! app()->isProduction()); + + //@codeCoverageIgnoreStart if (app()->isProduction()) { URL::forceScheme('https'); } + //@codeCoverageIgnoreEnd // @codeCoverageIgnoreStart if ($this->app->isLocal()) { diff --git a/app/Traits/DefaultLabelsFromEnum.php b/app/Traits/DefaultLabelsFromEnum.php new file mode 100644 index 0000000..92c9811 --- /dev/null +++ b/app/Traits/DefaultLabelsFromEnum.php @@ -0,0 +1,16 @@ + Str::headline(Str::lower($this->name)) + }; + } +} diff --git a/app/Traits/SelectOptionsFromEnum.php b/app/Traits/SelectOptionsFromEnum.php new file mode 100644 index 0000000..33869bd --- /dev/null +++ b/app/Traits/SelectOptionsFromEnum.php @@ -0,0 +1,17 @@ + 'string', 'value' => 'string'])] + public static function toSelectOptions(): array + { + return collect(self::cases())->transform(fn ($category) => [ + 'name' => $category->label(), + 'value' => $category->value, + ])->toArray(); + } +} diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index b7dbb6e..1479dbf 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -66,6 +66,10 @@ onMounted(() => { { label: 'Recipes', route: 'admin.recipes.index' + }, + { + label: 'Users', + route: 'admin.users.index' } ], }) diff --git a/resources/js/Pages/Admin/Recipe/RecipeIndex.vue b/resources/js/Pages/Admin/Recipe/RecipeIndex.vue index f119a97..6f4a1bc 100644 --- a/resources/js/Pages/Admin/Recipe/RecipeIndex.vue +++ b/resources/js/Pages/Admin/Recipe/RecipeIndex.vue @@ -35,17 +35,6 @@ const reloadTableData = () => { router.reload({ only: ['recipes'], preserveScroll: true, }) } -const saveNew = () => { - form.post(route("admin.recipes.store"), { - preserveScroll: true, - onSuccess: () => { - reloadTableData(); - form.reset() - } - }); -} - -const showNewForm = ref(false); const form = useForm({ name: null, }); @@ -91,22 +80,6 @@ const restoreRowData = (data) => { -
- - - - -