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) => {
-
diff --git a/resources/js/Pages/Admin/User/UserEdit.vue b/resources/js/Pages/Admin/User/UserEdit.vue
new file mode 100644
index 0000000..bbd3fc2
--- /dev/null
+++ b/resources/js/Pages/Admin/User/UserEdit.vue
@@ -0,0 +1,137 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/Pages/Admin/User/UserIndex.vue b/resources/js/Pages/Admin/User/UserIndex.vue
new file mode 100644
index 0000000..45cdaaa
--- /dev/null
+++ b/resources/js/Pages/Admin/User/UserIndex.vue
@@ -0,0 +1,130 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ data.name }}
+
+
+
+
+
+ {{ data.email }}
+
+
+
+
+ {{ data.recipes_count }}
+
+
+
+
+ {{ data.role }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/resources/js/Pages/Recipe/RecipeShow.vue b/resources/js/Pages/Recipe/RecipeShow.vue
index 91dda04..08cf595 100644
--- a/resources/js/Pages/Recipe/RecipeShow.vue
+++ b/resources/js/Pages/Recipe/RecipeShow.vue
@@ -82,7 +82,7 @@ const humanReadableDuration = (durationInMinutes) => {
class="w-full max-h-64"
>
-
+
diff --git a/routes/web.php b/routes/web.php
index 0cb94e8..aa2850f 100644
--- a/routes/web.php
+++ b/routes/web.php
@@ -4,6 +4,7 @@
use App\Http\Controllers\Admin\ImageController;
use App\Http\Controllers\Admin\LabelController as AdminLabelController;
use App\Http\Controllers\Admin\RecipeController as AdminRecipeController;
+use App\Http\Controllers\Admin\UserController;
use App\Http\Controllers\CategoryController;
use App\Http\Controllers\CategoryListController;
use App\Http\Controllers\HomeController;
@@ -50,6 +51,9 @@
->withTrashed()
->name('categories.restore');
Route::resource('categories', AdminCategoryController::class)->except('show', 'create');
+
+ // users
+ Route::resource('users', UserController::class)->except('show');
});
/**
diff --git a/tests/Feature/Http/Controllers/Admin/AdminRecipeControllerTest.php b/tests/Feature/Http/Controllers/Admin/AdminRecipeControllerTest.php
index e16a882..1a13a41 100644
--- a/tests/Feature/Http/Controllers/Admin/AdminRecipeControllerTest.php
+++ b/tests/Feature/Http/Controllers/Admin/AdminRecipeControllerTest.php
@@ -18,11 +18,14 @@ final class AdminRecipeControllerTest extends TestCase
protected User $nonAdminUser;
+ protected User $contributorUser;
+
public function setUp(): void
{
parent::setUp();
$this->adminUser = User::factory()->create(['role' => UserRoleEnum::ADMIN]);
+ $this->contributorUser = User::factory()->create(['role' => UserRoleEnum::CONTRIBUTOR]);
$this->nonAdminUser = User::factory()->create(['role' => null]);
}
@@ -40,6 +43,20 @@ public function index_displays_view_to_admin(): void
);
}
+ #[Test]
+ public function index_displays_view_to_contributor(): void
+ {
+ Recipe::factory()->count(3)->create();
+ $this->actingAs($this->contributorUser);
+ $response = $this->get(route('admin.recipes.index'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/Recipe/RecipeIndex')
+ ->has('recipes', 3)
+ );
+ }
+
#[Test]
public function store_submit_works_as_admin(): void
{
@@ -64,6 +81,30 @@ public function store_submit_works_as_admin(): void
);
}
+ #[Test]
+ public function store_submit_works_as_contributor(): void
+ {
+ Recipe::factory()->count(3)->create();
+ $this->actingAs($this->contributorUser);
+ $response = $this->followingRedirects()->post(route('admin.recipes.store'), [
+ 'name' => 'Foo',
+ 'serving' => '2 Foos',
+ 'ingredients' => '## Foo header',
+ 'instructions' => 'Mix all of the Foos',
+ 'description' => 'I am describing all of the Foos',
+ 'cook_time' => 15,
+ 'prep_time' => 10,
+ 'labelsSelected' => [],
+ 'categoriesSelected' => [],
+ ]);
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/Recipe/RecipeIndex')
+ ->has('recipes', 4)
+ );
+ }
+
#[Test]
public function update_submit_works_as_admin(): void
{
@@ -90,6 +131,32 @@ public function update_submit_works_as_admin(): void
$this->assertSame('Foo', $recipe->name);
}
+ #[Test]
+ public function update_submit_works_as_contributor(): void
+ {
+ Recipe::factory()->count(3)->create();
+ $recipe = Recipe::first();
+
+ $this->actingAs($this->contributorUser);
+
+ $response = $this->followingRedirects()->put(route('admin.recipes.update', ['recipe' => $recipe]), [
+ 'name' => 'Foo',
+ 'serving' => '3 Foos',
+ 'ingredients' => '## Foo header',
+ 'instructions' => 'Mix all of the Foos',
+ 'description' => 'I am describing all of the Foos',
+ 'cook_time' => 15,
+ 'prep_time' => 10,
+ 'labelsSelected' => [],
+ 'categoriesSelected' => [],
+ ]);
+
+ $response->assertOk();
+ $recipe->refresh();
+
+ $this->assertSame('Foo', $recipe->name);
+ }
+
#[Test]
public function edit_works_as_admin(): void
{
@@ -106,6 +173,22 @@ public function edit_works_as_admin(): void
);
}
+ #[Test]
+ public function edit_works_as_contributor(): void
+ {
+ Recipe::factory()->count(3)->create();
+ $recipe = Recipe::first();
+
+ $this->actingAs($this->contributorUser);
+ $response = $this->get(route('admin.recipes.edit', ['recipe' => $recipe]));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/Recipe/RecipeEdit')
+ ->where('recipe.name', $recipe->name)
+ );
+ }
+
#[Test]
public function create_works_as_admin(): void
{
@@ -123,6 +206,23 @@ public function create_works_as_admin(): void
);
}
+ #[Test]
+ public function create_works_as_contributor(): void
+ {
+ Recipe::factory()->count(3)->create();
+ $recipe = Recipe::first();
+
+ $this->actingAs($this->contributorUser);
+ $response = $this->get(route('admin.recipes.create'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/Recipe/RecipeEdit')
+ ->missing('recipe.name')
+ ->missing('recipe.id')
+ );
+ }
+
#[Test]
public function destroy_submit_works_as_admin(): void
{
@@ -141,6 +241,24 @@ public function destroy_submit_works_as_admin(): void
$this->assertSame(1, Recipe::onlyTrashed()->count());
}
+ #[Test]
+ public function destroy_submit_works_as_contributor(): void
+ {
+ Recipe::factory()->count(3)->create();
+ $recipe = Recipe::first();
+
+ $this->actingAs($this->contributorUser);
+ $response = $this->followingRedirects()->delete(route('admin.recipes.destroy', ['recipe' => $recipe]));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/Recipe/RecipeIndex')
+ ->has('recipes', 3)
+ );
+
+ $this->assertSame(1, Recipe::onlyTrashed()->count());
+ }
+
#[Test]
public function restore_submit_works_as_admin(): void
{
@@ -162,6 +280,27 @@ public function restore_submit_works_as_admin(): void
$this->assertSame(0, Recipe::onlyTrashed()->count());
}
+ #[Test]
+ public function restore_submit_works_as_contributor(): void
+ {
+ Recipe::factory()->count(3)->create();
+ $recipe = Recipe::first();
+ $recipe->delete();
+
+ $this->assertSame(1, Recipe::onlyTrashed()->count());
+
+ $this->actingAs($this->contributorUser);
+ $response = $this->followingRedirects()->put(route('admin.recipes.restore', ['recipe' => $recipe]));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/Recipe/RecipeIndex')
+ ->has('recipes', 3)
+ );
+
+ $this->assertSame(0, Recipe::onlyTrashed()->count());
+ }
+
#[Test]
public function index_does_not_display_view_to_nonadmin(): void
{
diff --git a/tests/Feature/Http/Controllers/Admin/AdminUserControllerTest.php b/tests/Feature/Http/Controllers/Admin/AdminUserControllerTest.php
new file mode 100644
index 0000000..4e8b7eb
--- /dev/null
+++ b/tests/Feature/Http/Controllers/Admin/AdminUserControllerTest.php
@@ -0,0 +1,147 @@
+adminUser = User::factory()->create(['role' => UserRoleEnum::ADMIN]);
+ $this->contributorUser = User::factory()->create(['role' => UserRoleEnum::CONTRIBUTOR]);
+ $this->nonAdminUser = User::factory()->create(['role' => null]);
+ }
+
+ #[Test]
+ public function index_displays_view_to_admin(): void
+ {
+ $this->actingAs($this->adminUser);
+ $response = $this->get(route('admin.users.index'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/User/UserIndex')
+ ->has('users', 3)
+ );
+ }
+
+ #[Test]
+ public function index_does_not_display_view_to_contributor(): void
+ {
+ $this->actingAs($this->contributorUser);
+ $response = $this->followingRedirects()->get(route('admin.users.index'));
+
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Dashboard')
+ );
+ }
+
+ #[Test]
+ public function store_submit_works_as_admin(): void
+ {
+ $this->actingAs($this->adminUser);
+ $response = $this->followingRedirects()->post(route('admin.users.store'), [
+ 'name' => 'Foo',
+ 'email' => 'foo@bar.com',
+ 'password' => 'Password!',
+ 'password_confirmation' => 'Password!',
+ 'role' => UserRoleEnum::ADMIN->value,
+ ]);
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/User/UserIndex')
+ ->has('users', 4)
+ );
+ }
+
+ #[Test]
+ public function update_submit_works_as_admin(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($this->adminUser);
+
+ $response = $this->followingRedirects()->put(route('admin.users.update', ['user' => $user]), [
+ 'name' => 'Foo1',
+ 'email' => 'foo1@bar.com',
+ 'role' => UserRoleEnum::CONTRIBUTOR->value,
+ ]);
+
+ $response->assertOk();
+ $user->refresh();
+
+ $this->assertSame('Foo1', $user->name);
+ }
+
+ #[Test]
+ public function edit_works_as_admin(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($this->adminUser);
+ $response = $this->get(route('admin.users.edit', ['user' => $user]));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/User/UserEdit')
+ ->where('user.name', $user->name)
+ );
+ }
+
+ #[Test]
+ public function create_works_as_admin(): void
+ {
+ $this->actingAs($this->adminUser);
+ $response = $this->get(route('admin.users.create'));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/User/UserEdit')
+ ->missing('user.name')
+ ->missing('user.id')
+ );
+ }
+
+ #[Test]
+ public function destroy_submit_works_as_admin(): void
+ {
+ $user = User::factory()->create();
+
+ $this->actingAs($this->adminUser);
+ $response = $this->followingRedirects()->delete(route('admin.users.destroy', ['user' => $user]));
+
+ $response->assertOk();
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Admin/User/UserIndex')
+ ->has('users', 3)
+ );
+ }
+
+ #[Test]
+ public function index_does_not_display_view_to_nonadmin(): void
+ {
+ $this->actingAs($this->nonAdminUser);
+ $response = $this->followingRedirects()->get(route('admin.users.index'));
+
+ $response->assertInertia(fn (Assert $page) => $page
+ ->component('Dashboard')
+ );
+ }
+}