Skip to content

Commit

Permalink
Merge pull request #9 from IronSinew/blake-user-management
Browse files Browse the repository at this point in the history
Add user management
  • Loading branch information
IronSinew authored Mar 26, 2024
2 parents 3ec260d + 8126368 commit a30edac
Show file tree
Hide file tree
Showing 17 changed files with 698 additions and 29 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,4 @@ cert.pem
/.blueprint
/.editorconfig
/storage/media-library/*
/.run
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions app/Enums/UserRoleEnum.php
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
82 changes: 82 additions & 0 deletions app/Http/Controllers/Admin/UserController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
<?php

namespace App\Http\Controllers\Admin;

use App\Enums\BannerTypeEnum;
use App\Enums\UserRoleEnum;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\Rule;
use Inertia\Inertia;

class UserController extends Controller
{
public function index()
{
return Inertia::render('Admin/User/UserIndex')->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);
}
}
3 changes: 3 additions & 0 deletions app/Models/Recipe.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions app/Models/User.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
3 changes: 3 additions & 0 deletions app/Providers/AppServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand Down
16 changes: 16 additions & 0 deletions app/Traits/DefaultLabelsFromEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace App\Traits;

use Illuminate\Support\Str;

trait DefaultLabelsFromEnum
{
/** @codeCoverageIgnore */
public function label(): string
{
return match ($this) {
default => Str::headline(Str::lower($this->name))
};
}
}
17 changes: 17 additions & 0 deletions app/Traits/SelectOptionsFromEnum.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?php

namespace App\Traits;

use JetBrains\PhpStorm\ArrayShape;

trait SelectOptionsFromEnum
{
#[ArrayShape(['name' => 'string', 'value' => 'string'])]
public static function toSelectOptions(): array
{
return collect(self::cases())->transform(fn ($category) => [
'name' => $category->label(),
'value' => $category->value,
])->toArray();
}
}
4 changes: 4 additions & 0 deletions resources/js/Layouts/AppLayout.vue
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,10 @@ onMounted(() => {
{
label: 'Recipes',
route: 'admin.recipes.index'
},
{
label: 'Users',
route: 'admin.users.index'
}
],
})
Expand Down
27 changes: 0 additions & 27 deletions resources/js/Pages/Admin/Recipe/RecipeIndex.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down Expand Up @@ -91,22 +80,6 @@ const restoreRowData = (data) => {
<Button label="Add New"></Button>
</Link>
</div>
<form @submit.prevent="saveNew" :class="[showNewForm ? 'scale-1 h-full' : 'scale-0 h-0']" class="transition-all ease-in-out delay-150 duration-500 mb-5">
<Card>
<template #content>
<h5 class="text-xl font-bold text-primary-200 mb-3">New Recipe</h5>
<InputText v-model="form.name" placeholder="Recipe Name" class="font-normal"/>
<div :class="[form.errors.name ? 'opacity-100' : 'opacity-0']" class="transition-opacity ease-in-out delay-150 duration-300 pt-4 text-sm text-red-500 font-bold">
{{ form.errors.name }}
</div>
</template>
<template #footer>
<div class="text-right">
<Button type="submit" severity="success" label="Save" class="text-right" :disabled="form.processing"></Button>
</div>
</template>
</Card>
</form>
<DataTable :value="tableData" tableStyle="min-width: 50rem" striped-rows>
<Column header="Name" class="text-surface-700 dark:text-white/70">
<template #body="{ data }">
Expand Down
137 changes: 137 additions & 0 deletions resources/js/Pages/Admin/User/UserEdit.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
import Breadcrumb from 'primevue/breadcrumb';
import Button from "primevue/button";
import Dropdown from 'primevue/dropdown';
import InputText from "primevue/inputtext";
import Password from 'primevue/password';
import {router, useForm, Link} from "@inertiajs/vue3";
import {onMounted, ref, watch} from "vue";
const props = defineProps({
user: {
type: [Array, Object],
required: false,
default() {
return [];
},
},
roles: {
type: [Array, Object],
required: false,
default() {
return [];
},
}
});
const form = useForm({
id: null,
name: null,
email: null,
password: null,
password_confirmation: null,
role: null,
});
const save = () => {
if (form.id) {
form.put(route("admin.users.update", {user: form.id}));
} else {
form.post(route("admin.users.store"), {
preserveScroll: true,
onSuccess: () => {
form.reset()
}
});
}
}
const breadcrumbs = ref([
{ label: 'Users', url: route("admin.users.index") },
{ label: props.user.id ? `Edit User` : "New User" }
]);
onMounted(() => {
for (const [key, value] of Object.entries(props.user)) {
form[key] = value;
}
});
</script>

<template>
<AppLayout title="User Admin">
<template #header>
<Breadcrumb :model="breadcrumbs"/>
</template>
<div class="py-12">
<div class="max-w-7xl mx-auto sm:px-6 lg:px-8">
<form @submit.prevent="save" class="mb-5">
<h5 class="text-xl font-bold text-primary-200 mb-3">
{{ props.user.id ? `Edit ${props.user.name}` : "New User" }}
</h5>
<div class="grid grid-cols-3 gap-x-6">
<div class="my-7">
<label>User's Name</label>
<InputText v-model="form.name" class="font-normal w-full"/>
<div :class="[form.errors.name ? 'opacity-100' : 'opacity-0']"
class="transition-opacity ease-in-out delay-150 duration-300 pt-4 text-sm text-red-500 font-bold">
{{ form.errors.name }}
</div>
</div>

<div class="my-7">
<label>Email</label>
<InputText v-model="form.email" class="font-normal w-full"/>
<div :class="[form.errors.email ? 'opacity-100' : 'opacity-0']"
class="transition-opacity ease-in-out delay-150 duration-300 pt-4 text-sm text-red-500 font-bold">
{{ form.errors.email }}
</div>
</div>

<div class="my-7">
<label>Role</label>
<Dropdown
class="font-normal w-full"
v-model="form.role"
:options="roles"
option-label="name"
option-value="value"
placeholder="Member"
/>
<div :class="[form.errors.email ? 'opacity-100' : 'opacity-0']"
class="transition-opacity ease-in-out delay-150 duration-300 pt-4 text-sm text-red-500 font-bold">
{{ form.errors.email }}
</div>
</div>
</div>
<div v-if="! props.user.id" class="grid grid-cols-3 gap-x-6">
<div class="my-7">
<label>Password</label>
<Password v-model="form.password" class="font-normal w-full"/>
<div :class="[form.errors.password ? 'opacity-100' : 'opacity-0']"
class="transition-opacity ease-in-out delay-150 duration-300 pt-4 text-sm text-red-500 font-bold">
{{ form.errors.password }}
</div>
</div>

<div class="my-7">
<label>Confirm Password</label>
<Password v-model="form.password_confirmation" class="font-normal w-full"/>
<div :class="[form.errors.password_confirmation ? 'opacity-100' : 'opacity-0']"
class="transition-opacity ease-in-out delay-150 duration-300 pt-4 text-sm text-red-500 font-bold">
{{ form.errors.password_confirmation }}
</div>
</div>
</div>


<div class="text-right">
<Button type="submit" severity="success" label="Save" class="text-right"
:disabled="form.processing"></Button>
</div>
</form>
</div>
</div>
</AppLayout>
</template>
Loading

0 comments on commit a30edac

Please sign in to comment.