From c769b7aafe5ffc38da3a5f2fb52f420bac8dd438 Mon Sep 17 00:00:00 2001 From: Toy Rik Date: Tue, 9 Jun 2026 14:51:59 +0300 Subject: [PATCH] =?UTF-8?q?=D0=BF=D0=BE=D0=BB=D1=8C=D0=B7=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=82=D0=B5=D0=BB=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/Domain/Role/Actions/DestroyAction.php | 34 +++ src/app/Domain/Role/Actions/IndexAction.php | 31 +++ src/app/Domain/Role/Actions/ShowAction.php | 23 ++ src/app/Domain/Role/Actions/StoreAction.php | 26 +++ src/app/Domain/Role/Actions/UpdateAction.php | 22 ++ src/app/Domain/Role/Data/DestroyRequest.php | 23 ++ src/app/Domain/Role/Data/IndexRequest.php | 29 +++ .../Domain/Role/Data/IndexResponseData.php | 38 ++++ .../Domain/Role/Data/IndexRoleItemData.php | 33 +++ src/app/Domain/Role/Data/ShowRequest.php | 23 ++ src/app/Domain/Role/Data/ShowResponseData.php | 35 +++ src/app/Domain/Role/Data/StoreRequest.php | 26 +++ src/app/Domain/Role/Data/UpdateRequest.php | 40 ++++ .../Role/Repositories/RoleRepository.php | 13 ++ src/app/Domain/User/Actions/DestroyAction.php | 38 ++++ src/app/Domain/User/Actions/IndexAction.php | 43 ++++ src/app/Domain/User/Actions/StoreAction.php | 28 +++ src/app/Domain/User/Actions/UpdateAction.php | 25 +- src/app/Domain/User/Data/DestroyRequest.php | 23 ++ src/app/Domain/User/Data/IndexRequest.php | 33 +++ .../Domain/User/Data/IndexResponseData.php | 38 ++++ .../Domain/User/Data/IndexUserItemData.php | 37 +++ src/app/Domain/User/Data/StoreRequest.php | 35 +++ src/app/Domain/User/Data/UpdateRequest.php | 16 +- src/app/Helpers/MenuHelper.php | 44 +++- src/app/Http/Controllers/RoleController.php | 46 +++- src/app/Http/Controllers/UserController.php | 34 ++- .../Http/Middleware/CheckRoleMiddleware.php | 9 +- src/app/Providers/AppServiceProvider.php | 10 +- src/app/Providers/FortifyServiceProvider.php | 12 +- src/database/factories/UserFactory.php | 8 + .../components/profile/profile-card.blade.php | 14 +- src/resources/views/dashboard.blade.php | 66 +++++- .../views/pages/role/index.blade.php | 91 ++++++++ src/resources/views/pages/role/show.blade.php | 39 ++++ .../views/pages/user/index.blade.php | 100 +++++++- src/routes/web.php | 27 +++ src/tests/Feature/RoleControllerTest.php | 166 ++++++++++++++ src/tests/Feature/UserControllerTest.php | 215 +++++++++++++++++- 39 files changed, 1555 insertions(+), 38 deletions(-) create mode 100644 src/app/Domain/Role/Actions/DestroyAction.php create mode 100644 src/app/Domain/Role/Actions/IndexAction.php create mode 100644 src/app/Domain/Role/Actions/ShowAction.php create mode 100644 src/app/Domain/Role/Actions/StoreAction.php create mode 100644 src/app/Domain/Role/Actions/UpdateAction.php create mode 100644 src/app/Domain/Role/Data/DestroyRequest.php create mode 100644 src/app/Domain/Role/Data/IndexRequest.php create mode 100644 src/app/Domain/Role/Data/IndexResponseData.php create mode 100644 src/app/Domain/Role/Data/IndexRoleItemData.php create mode 100644 src/app/Domain/Role/Data/ShowRequest.php create mode 100644 src/app/Domain/Role/Data/ShowResponseData.php create mode 100644 src/app/Domain/Role/Data/StoreRequest.php create mode 100644 src/app/Domain/Role/Data/UpdateRequest.php create mode 100644 src/app/Domain/Role/Repositories/RoleRepository.php create mode 100644 src/app/Domain/User/Actions/DestroyAction.php create mode 100644 src/app/Domain/User/Actions/IndexAction.php create mode 100644 src/app/Domain/User/Actions/StoreAction.php create mode 100644 src/app/Domain/User/Data/DestroyRequest.php create mode 100644 src/app/Domain/User/Data/IndexRequest.php create mode 100644 src/app/Domain/User/Data/IndexResponseData.php create mode 100644 src/app/Domain/User/Data/IndexUserItemData.php create mode 100644 src/app/Domain/User/Data/StoreRequest.php create mode 100644 src/resources/views/pages/role/index.blade.php create mode 100644 src/resources/views/pages/role/show.blade.php create mode 100644 src/tests/Feature/RoleControllerTest.php diff --git a/src/app/Domain/Role/Actions/DestroyAction.php b/src/app/Domain/Role/Actions/DestroyAction.php new file mode 100644 index 0000000..6acc595 --- /dev/null +++ b/src/app/Domain/Role/Actions/DestroyAction.php @@ -0,0 +1,34 @@ +roleRepository->whereUuid($request->role_uuid)->firstOrFail(); + + // Нельзя удалить роль, если к ней привязаны пользователи + if ($role->users()->count() > 0) { + AppException::new( + 'role_in_use', + 'Нельзя удалить роль, к которой привязаны пользователи', + Response::HTTP_CONFLICT + ); + } + + $role->delete(); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Actions/IndexAction.php b/src/app/Domain/Role/Actions/IndexAction.php new file mode 100644 index 0000000..fc06a90 --- /dev/null +++ b/src/app/Domain/Role/Actions/IndexAction.php @@ -0,0 +1,31 @@ +roleRepository + ->withCount('users') + ->orderBy($request->sort_by ?? 'name', $request->sort_dir ?? 'asc'); + + $paginator = $query->paginate( + perPage: $request->per_page ?? 15, + page: $request->page ?? 1, + ); + + return IndexResponseData::fromPaginator($paginator); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Actions/ShowAction.php b/src/app/Domain/Role/Actions/ShowAction.php new file mode 100644 index 0000000..3228c82 --- /dev/null +++ b/src/app/Domain/Role/Actions/ShowAction.php @@ -0,0 +1,23 @@ +roleRepository->whereUuid($request->role_uuid)->firstOrFail(); + return ShowResponseData::fromModel($result); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Actions/StoreAction.php b/src/app/Domain/Role/Actions/StoreAction.php new file mode 100644 index 0000000..2c00f5f --- /dev/null +++ b/src/app/Domain/Role/Actions/StoreAction.php @@ -0,0 +1,26 @@ +roleRepository->create([ + 'name' => $request->name, + 'code' => $request->code, + 'description' => $request->description, + ]); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Actions/UpdateAction.php b/src/app/Domain/Role/Actions/UpdateAction.php new file mode 100644 index 0000000..d9d63ff --- /dev/null +++ b/src/app/Domain/Role/Actions/UpdateAction.php @@ -0,0 +1,22 @@ +roleRepository->whereUuid($request->role_uuid)->firstOrFail(); + $role->update($request->getFilledFields()); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Data/DestroyRequest.php b/src/app/Domain/Role/Data/DestroyRequest.php new file mode 100644 index 0000000..b975b96 --- /dev/null +++ b/src/app/Domain/Role/Data/DestroyRequest.php @@ -0,0 +1,23 @@ + */ + public readonly DataCollection $roles, + public readonly int $current_page, + public readonly int $last_page, + public readonly int $per_page, + public readonly int $total, + ) { + } + + public static function fromPaginator(LengthAwarePaginator $paginator): self + { + $items = $paginator->getCollection()->map( + fn (Role $role) => IndexRoleItemData::fromModel($role) + ); + + return new self( + roles: new DataCollection(IndexRoleItemData::class, $items), + current_page: $paginator->currentPage(), + last_page: $paginator->lastPage(), + per_page: $paginator->perPage(), + total: $paginator->total(), + ); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Data/IndexRoleItemData.php b/src/app/Domain/Role/Data/IndexRoleItemData.php new file mode 100644 index 0000000..98abe1c --- /dev/null +++ b/src/app/Domain/Role/Data/IndexRoleItemData.php @@ -0,0 +1,33 @@ +uuid->toString(), + name: $model->name, + code: $model->code, + description: $model->description, + users_count: $model->users_count ?? $model->users()->count(), + created_at: $model->created_at->toDateTimeString(), + ); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Data/ShowRequest.php b/src/app/Domain/Role/Data/ShowRequest.php new file mode 100644 index 0000000..6b503a4 --- /dev/null +++ b/src/app/Domain/Role/Data/ShowRequest.php @@ -0,0 +1,23 @@ +uuid->toString(), + name: $model->name, + code: $model->code, + description: $model->description, + users_count: $model->users()->count(), + created_at: $model->created_at->toDateTimeString(), + updated_at: $model->updated_at->toDateTimeString(), + ); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Data/StoreRequest.php b/src/app/Domain/Role/Data/StoreRequest.php new file mode 100644 index 0000000..d58c14b --- /dev/null +++ b/src/app/Domain/Role/Data/StoreRequest.php @@ -0,0 +1,26 @@ + $this->name, + 'code' => $this->code, + 'description' => $this->description, + ], fn ($value) => $value !== null); + } +} \ No newline at end of file diff --git a/src/app/Domain/Role/Repositories/RoleRepository.php b/src/app/Domain/Role/Repositories/RoleRepository.php new file mode 100644 index 0000000..85a9b9c --- /dev/null +++ b/src/app/Domain/Role/Repositories/RoleRepository.php @@ -0,0 +1,13 @@ +uuid->toString() === $request->user_uuid) { + AppException::new('self_delete', 'Нельзя удалить самого себя', Response::HTTP_FORBIDDEN); + } + + $user = $this->userRepository->whereUuid($request->user_uuid)->firstOrFail(); + $user->delete(); + } +} \ No newline at end of file diff --git a/src/app/Domain/User/Actions/IndexAction.php b/src/app/Domain/User/Actions/IndexAction.php new file mode 100644 index 0000000..98413a0 --- /dev/null +++ b/src/app/Domain/User/Actions/IndexAction.php @@ -0,0 +1,43 @@ +userRepository + ->with('role') + ->orderBy($request->sort_by ?? 'created_at', $request->sort_dir ?? 'desc'); + + if ($request->search) { + $search = $request->search; + $query->where(function ($q) use ($search): void { + $q->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + } + + if ($request->role) { + $query->where('role_uuid', $request->role); + } + + $paginator = $query->paginate( + perPage: $request->per_page ?? 15, + page: $request->page ?? 1, + ); + + return IndexResponseData::fromPaginator($paginator); + } +} \ No newline at end of file diff --git a/src/app/Domain/User/Actions/StoreAction.php b/src/app/Domain/User/Actions/StoreAction.php new file mode 100644 index 0000000..d459dcf --- /dev/null +++ b/src/app/Domain/User/Actions/StoreAction.php @@ -0,0 +1,28 @@ +userRepository->create([ + 'name' => $request->name, + 'email' => $request->email, + 'password' => bcrypt($request->password), + 'role_uuid' => $request->role_uuid, + 'email_verified_at' => now(), + ]); + } +} \ No newline at end of file diff --git a/src/app/Domain/User/Actions/UpdateAction.php b/src/app/Domain/User/Actions/UpdateAction.php index 003b6c3..a514d42 100644 --- a/src/app/Domain/User/Actions/UpdateAction.php +++ b/src/app/Domain/User/Actions/UpdateAction.php @@ -6,7 +6,10 @@ namespace App\Domain\User\Actions; use App\Domain\User\Data\UpdateRequest; use App\Domain\User\Repositories\UserRepository; -use Illuminate\Http\JsonResponse; +use App\Domain\Shared\Exceptions\AppException; +use App\Models\User; +use Illuminate\Support\Facades\Auth; +use Symfony\Component\HttpFoundation\Response; class UpdateAction { @@ -18,6 +21,26 @@ class UpdateAction public function execute(UpdateRequest $request) { + // Получаем текущего аутентифицированного пользователя + /** @var User|null $currentUser */ + $currentUser = Auth::user(); + + // Проверяем, что пользователь аутентифицирован + if (!$currentUser) { + AppException::new('UNAUTHORIZED', 'UNAUTHORIZED', Response::HTTP_UNAUTHORIZED); + } + + // Загружаем роль текущего пользователя + $currentUser->load('role'); + + // Проверяем, является ли пользователь администратором + $isAdmin = $currentUser->role && $currentUser->role->code === 'admin'; + + // Если пользователь не администратор и пытается редактировать не себя, запрещаем доступ + if (!$isAdmin && $currentUser->uuid->toString() !== $request->user_uuid) { + AppException::new('forbidden', 'Недостаточно прав для редактирования этого пользователя', Response::HTTP_FORBIDDEN); + } + $user = $this->userRepository->whereUuid($request->user_uuid)->firstOrFail(); $user->update($request->getFilledFields()); } diff --git a/src/app/Domain/User/Data/DestroyRequest.php b/src/app/Domain/User/Data/DestroyRequest.php new file mode 100644 index 0000000..498f05a --- /dev/null +++ b/src/app/Domain/User/Data/DestroyRequest.php @@ -0,0 +1,23 @@ + */ + public readonly DataCollection $users, + public readonly int $current_page, + public readonly int $last_page, + public readonly int $per_page, + public readonly int $total, + ) { + } + + public static function fromPaginator(LengthAwarePaginator $paginator): self + { + $items = $paginator->getCollection()->map( + fn (User $user) => IndexUserItemData::fromModel($user) + ); + + return new self( + users: new DataCollection(IndexUserItemData::class, $items), + current_page: $paginator->currentPage(), + last_page: $paginator->lastPage(), + per_page: $paginator->perPage(), + total: $paginator->total(), + ); + } +} \ No newline at end of file diff --git a/src/app/Domain/User/Data/IndexUserItemData.php b/src/app/Domain/User/Data/IndexUserItemData.php new file mode 100644 index 0000000..658e6b3 --- /dev/null +++ b/src/app/Domain/User/Data/IndexUserItemData.php @@ -0,0 +1,37 @@ +uuid->toString(), + name: $model->name, + email: $model->email, + email_verified: $model->email_verified_at !== null, + role: new RoleData( + uuid: $model->role->uuid->toString(), + name: $model->role->name, + ), + created_at: $model->created_at->toDateTimeString(), + ); + } +} \ No newline at end of file diff --git a/src/app/Domain/User/Data/StoreRequest.php b/src/app/Domain/User/Data/StoreRequest.php new file mode 100644 index 0000000..9176a5e --- /dev/null +++ b/src/app/Domain/User/Data/StoreRequest.php @@ -0,0 +1,35 @@ + $this->name, 'email' => $this->email, + 'role_uuid' => $this->role_uuid, 'password' => $this->password ? bcrypt($this->password) : null, ]; diff --git a/src/app/Helpers/MenuHelper.php b/src/app/Helpers/MenuHelper.php index be57b2d..79c0885 100644 --- a/src/app/Helpers/MenuHelper.php +++ b/src/app/Helpers/MenuHelper.php @@ -12,13 +12,33 @@ class MenuHelper [ 'icon' => 'dashboard', 'name' => 'Dashboard', - 'path' => route('dashboard') + 'path' => route('dashboard'), ], [ 'icon' => 'user-profile', 'name' => 'User Profile', - 'path' => auth()->check() ? route('users.show', ['user_uuid' => auth()->user()->uuid]) : '#' - ] + 'path' => auth()->check() ? route('users.show', ['user_uuid' => auth()->user()->uuid]) : '#', + ], + ]; + } + + public static function getAdminNavItems(): array + { + if (!auth()->check() || auth()->user()->role->code !== 'admin') { + return []; + } + + return [ + [ + 'icon' => 'users', + 'name' => 'Users', + 'path' => route('users.index'), + ], + [ + 'icon' => 'roles', + 'name' => 'Roles', + 'path' => route('roles.index'), + ], ]; } @@ -34,23 +54,29 @@ class MenuHelper 'title' => 'Menu', 'items' => self::getMainNavItems(), ], + [ + 'title' => 'Administration', + 'items' => self::getAdminNavItems(), + ], [ 'title' => 'Others', - 'items' => self::getOterItems() - ] + 'items' => self::getOterItems(), + ], ]; } - public static function isActive($path) + public static function isActive($path): bool { - return request()->is($path, '/'); + return request()->is(ltrim($path, '/'), '/'); } - public static function getIconSvg($iconName) + public static function getIconSvg($iconName): string { $icons = [ 'dashboard' => 'Dashboard SVG Icon', - 'user-profile' => 'User-avatar SVG Icon' + 'user-profile' => 'User-avatar SVG Icon', + 'users' => 'Users SVG Icon', + 'roles' => 'Roles SVG Icon', ]; return $icons[$iconName] ?? ''; } diff --git a/src/app/Http/Controllers/RoleController.php b/src/app/Http/Controllers/RoleController.php index 4507fa4..95c9936 100644 --- a/src/app/Http/Controllers/RoleController.php +++ b/src/app/Http/Controllers/RoleController.php @@ -4,7 +4,51 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Domain\Role\Actions\DestroyAction; +use App\Domain\Role\Actions\IndexAction; +use App\Domain\Role\Actions\ShowAction; +use App\Domain\Role\Actions\StoreAction; +use App\Domain\Role\Actions\UpdateAction; +use App\Domain\Role\Data\DestroyRequest; +use App\Domain\Role\Data\IndexRequest; +use App\Domain\Role\Data\ShowRequest; +use App\Domain\Role\Data\StoreRequest; +use App\Domain\Role\Data\UpdateRequest; +use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; + class RoleController extends Controller { - // + public function index(IndexRequest $request, IndexAction $action): View + { + $data = $action->execute($request); + return view('pages.role.index', ['data' => $data]); + } + + public function show(ShowRequest $request, ShowAction $action): View + { + $data = $action->execute($request); + return view('pages.role.show', ['data' => $data]); + } + + public function store(StoreRequest $request, StoreAction $action): JsonResponse + { + $action->execute($request); + + return response()->json(['success' => true, 'message' => 'Role created successfully']); + } + + public function update(UpdateRequest $request, UpdateAction $action): JsonResponse + { + $action->execute($request); + + return response()->json(['success' => true, 'message' => 'Role updated successfully']); + } + + public function destroy(DestroyRequest $request, DestroyAction $action): JsonResponse + { + $action->execute($request); + + return response()->json(['success' => true, 'message' => 'Role deleted successfully']); + } } diff --git a/src/app/Http/Controllers/UserController.php b/src/app/Http/Controllers/UserController.php index bde9402..54c44bd 100644 --- a/src/app/Http/Controllers/UserController.php +++ b/src/app/Http/Controllers/UserController.php @@ -4,17 +4,25 @@ declare(strict_types=1); namespace App\Http\Controllers; +use App\Domain\User\Actions\DestroyAction; +use App\Domain\User\Actions\IndexAction; use App\Domain\User\Actions\ShowAction; +use App\Domain\User\Actions\StoreAction; use App\Domain\User\Actions\UpdateAction; +use App\Domain\User\Data\DestroyRequest; +use App\Domain\User\Data\IndexRequest; use App\Domain\User\Data\ShowRequest; +use App\Domain\User\Data\StoreRequest; use App\Domain\User\Data\UpdateRequest; use Illuminate\Contracts\View\View; +use Illuminate\Http\JsonResponse; class UserController extends Controller { - public function index(): View + public function index(IndexRequest $request, IndexAction $action): View { - return view('pages.user.index', []); + $data = $action->execute($request); + return view('pages.user.index', ['data' => $data]); } public function show(ShowRequest $request, ShowAction $action): View @@ -22,11 +30,25 @@ class UserController extends Controller $data = $action->execute($request); return view('pages.user.show', ['data' => $data]); } - - public function update(UpdateRequest $request, UpdateAction $action) + + public function store(StoreRequest $request, StoreAction $action): JsonResponse { - $data = $action->execute($request); - + $action->execute($request); + + return response()->json(['success' => true, 'message' => 'User created successfully']); + } + + public function update(UpdateRequest $request, UpdateAction $action): JsonResponse + { + $action->execute($request); + return response()->json(['success' => true, 'message' => 'Profile updated successfully']); } + + public function destroy(DestroyRequest $request, DestroyAction $action): JsonResponse + { + $action->execute($request); + + return response()->json(['success' => true, 'message' => 'User deleted successfully']); + } } diff --git a/src/app/Http/Middleware/CheckRoleMiddleware.php b/src/app/Http/Middleware/CheckRoleMiddleware.php index ca4f02a..8180849 100644 --- a/src/app/Http/Middleware/CheckRoleMiddleware.php +++ b/src/app/Http/Middleware/CheckRoleMiddleware.php @@ -33,13 +33,10 @@ class CheckRoleMiddleware AppException::new('UNAUTHORIZED', 'UNAUTHORIZED', Response::HTTP_UNAUTHORIZED); } - $userRole = $user->load('role')->role(); + $user->load('role'); - $hasRole = $userRole->whereIn('code', $roles) - ->count(); - - if (!$hasRole) { - AppException::new('forbidden', 'Недостаточно прав'); + if (!$user->role || !in_array($user->role->code, $roles, true)) { + AppException::new('forbidden', 'Недостаточно прав', Response::HTTP_FORBIDDEN); } return $next($request); diff --git a/src/app/Providers/AppServiceProvider.php b/src/app/Providers/AppServiceProvider.php index 8325058..20b69b1 100644 --- a/src/app/Providers/AppServiceProvider.php +++ b/src/app/Providers/AppServiceProvider.php @@ -4,6 +4,8 @@ declare(strict_types=1); namespace App\Providers; +use App\Domain\Role\Repositories\RoleRepository; +use App\Domain\User\Repositories\UserRepository; use Illuminate\Support\ServiceProvider; class AppServiceProvider extends ServiceProvider @@ -13,7 +15,13 @@ class AppServiceProvider extends ServiceProvider */ public function register(): void { - // + $this->app->singleton(UserRepository::class, function () { + return new UserRepository(); + }); + + $this->app->singleton(RoleRepository::class, function () { + return new RoleRepository(); + }); } /** diff --git a/src/app/Providers/FortifyServiceProvider.php b/src/app/Providers/FortifyServiceProvider.php index 8da5c3f..a09eff3 100644 --- a/src/app/Providers/FortifyServiceProvider.php +++ b/src/app/Providers/FortifyServiceProvider.php @@ -50,7 +50,7 @@ class FortifyServiceProvider extends ServiceProvider // Custom routes defined specifically Fortify::registerView(function () { - return view('auth.register'); + return view('pages.auth.register'); }); Fortify::loginView(function () { @@ -58,23 +58,23 @@ class FortifyServiceProvider extends ServiceProvider }); Fortify::verifyEmailView(function () { - return view('auth.verify-mail'); + return view('pages.auth.verify-mail'); }); Fortify::requestPasswordResetLinkView(function () { - return view('auth.forgot-password'); + return view('pages.auth.forgot-password'); }); Fortify::resetPasswordView(function (Request $request) { - return view('auth.reset-password', ['request' => $request]); + return view('pages.auth.reset-password', ['request' => $request]); }); Fortify::confirmPasswordView(function () { - return view('auth.confirm-password'); + return view('pages.auth.confirm-password'); }); Fortify::twoFactorChallengeView(function () { - return view('auth.two-factor-challange'); + return view('pages.auth.two-factor-challange'); }); } } diff --git a/src/database/factories/UserFactory.php b/src/database/factories/UserFactory.php index 2d3445e..5938825 100644 --- a/src/database/factories/UserFactory.php +++ b/src/database/factories/UserFactory.php @@ -43,4 +43,12 @@ class UserFactory extends Factory 'email_verified_at' => null, ]); } + + public function hasRole(Role|Factory|null $role = null): self + { + return $this->for( + $role ?? Role::factory(), + 'role' + ); + } } diff --git a/src/resources/views/components/profile/profile-card.blade.php b/src/resources/views/components/profile/profile-card.blade.php index be1e41e..4069ad7 100644 --- a/src/resources/views/components/profile/profile-card.blade.php +++ b/src/resources/views/components/profile/profile-card.blade.php @@ -3,6 +3,7 @@ name: '{{ $user->name }}', email: '{{ $user->email }}', password: '{{ $user->password ?? null }}', + role_uuid: '{{ $user->role->uuid ?? null }}', errors: {}, saveProfile() { // Очищаем предыдущие ошибки @@ -22,6 +23,10 @@ data.email = this.email; } + if (this.role_uuid) { + data.role_uuid = this.role_uuid; + } + if (this.password) { data.password = this.password; } @@ -187,10 +192,13 @@ @if(auth()->user()->role->code === 'admin')
- +
@endif diff --git a/src/resources/views/dashboard.blade.php b/src/resources/views/dashboard.blade.php index 569302f..8f5c431 100644 --- a/src/resources/views/dashboard.blade.php +++ b/src/resources/views/dashboard.blade.php @@ -1,5 +1,69 @@ @extends ('layouts.app') @section ('content') -

dashboard

+ + +
+
+
+
+

{{ __('Total Users') }}

+

{{ \App\Models\User::count() }}

+
+
+ + + + + + +
+
+ +
+ +
+
+
+

{{ __('Total Roles') }}

+

{{ \App\Models\Role::count() }}

+
+
+ + + + +
+
+ +
+ +
+
+
+

{{ __('Verified Users') }}

+

{{ \App\Models\User::whereNotNull('email_verified_at')->count() }}

+
+
+ + + + +
+
+
+

+ {{ round((\App\Models\User::whereNotNull('email_verified_at')->count() / max(\App\Models\User::count(), 1)) * 100) }}% {{ __('verified') }} +

+
+
+
@endsection diff --git a/src/resources/views/pages/role/index.blade.php b/src/resources/views/pages/role/index.blade.php new file mode 100644 index 0000000..bad1f5d --- /dev/null +++ b/src/resources/views/pages/role/index.blade.php @@ -0,0 +1,91 @@ +@extends ('layouts.app') + +@section ('content') + + +
+
+

{{ __('Roles List') }}

+ @if(auth()->user()?->role?->code === 'admin') + + @endif +
+ +
+ + + + + + + + + + + + + @forelse($data->roles as $role) + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Name') }}{{ __('Code') }}{{ __('Description') }}{{ __('Users') }}{{ __('Created') }}{{ __('Actions') }}
+ {{ $role->name }} + + + {{ $role->code }} + + {{ $role->description }}{{ $role->users_count }}{{ $role->created_at }} +
+ @if(auth()->user()?->role?->code === 'admin' && $role->users_count === 0) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
+ {{ __('No roles found') }} +
+
+ + @if($data->last_page > 1) +
+
+ {{ __('Page') }} {{ $data->current_page }} {{ __('of') }} {{ $data->last_page }} + ({{ $data->total }} {{ __('total') }}) +
+
+ @if($data->current_page > 1) + + ← {{ __('Previous') }} + + @endif + @if($data->current_page < $data->last_page) + + {{ __('Next') }} → + + @endif +
+
+ @endif +
+@endsection \ No newline at end of file diff --git a/src/resources/views/pages/role/show.blade.php b/src/resources/views/pages/role/show.blade.php new file mode 100644 index 0000000..09527bb --- /dev/null +++ b/src/resources/views/pages/role/show.blade.php @@ -0,0 +1,39 @@ +@extends ('layouts.app') + +@section ('content') + + +
+
+

{{ $data->name }}

+ + {{ $data->code }} + +
+ +
+
+ +

{{ $data->description }}

+
+
+ +

{{ $data->users_count }}

+
+
+ +

{{ $data->created_at }}

+
+
+ +

{{ $data->updated_at }}

+
+
+ + +
+@endsection \ No newline at end of file diff --git a/src/resources/views/pages/user/index.blade.php b/src/resources/views/pages/user/index.blade.php index fb54acb..71d571f 100644 --- a/src/resources/views/pages/user/index.blade.php +++ b/src/resources/views/pages/user/index.blade.php @@ -1,5 +1,103 @@ @extends ('layouts.app') @section ('content') -

Users index

+ + +
+
+

{{ __('Users List') }}

+ @if(auth()->user()?->role?->code === 'admin') + + @endif +
+ +
+ + + + + + + + + + + + + @forelse($data->users as $user) + + + + + + + + + @empty + + + + @endforelse + +
{{ __('Name') }}{{ __('Email') }}{{ __('Role') }}{{ __('Verified') }}{{ __('Created') }}{{ __('Actions') }}
+ + {{ $user->name }} + + {{ $user->email }} + + {{ $user->role->name }} + + + @if($user->email_verified) + + @else + + @endif + {{ $user->created_at }} +
+ + {{ __('View') }} + + @if(auth()->user()?->role?->code === 'admin' && auth()->user()?->uuid !== $user->uuid) +
+ @csrf + @method('DELETE') + +
+ @endif +
+
+ {{ __('No users found') }} +
+
+ + @if($data->last_page > 1) +
+
+ {{ __('Page') }} {{ $data->current_page }} {{ __('of') }} {{ $data->last_page }} + ({{ $data->total }} {{ __('total') }}) +
+
+ @if($data->current_page > 1) + + ← {{ __('Previous') }} + + @endif + @if($data->current_page < $data->last_page) + + {{ __('Next') }} → + + @endif +
+
+ @endif +
@endsection diff --git a/src/routes/web.php b/src/routes/web.php index 6221578..4a919c3 100644 --- a/src/routes/web.php +++ b/src/routes/web.php @@ -13,6 +13,7 @@ Route::get('/', function () { Route::middleware(['auth', 'verified'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index']) ->name('dashboard'); + Route::controller(UserController::class) ->prefix('/users') ->as('users.') @@ -20,12 +21,38 @@ Route::middleware(['auth', 'verified'])->group(function () { Route::get('/', 'index') ->name('index') ->middleware('role:admin'); + Route::post('/', 'store') + ->name('store') + ->middleware('role:admin'); Route::get('/{user_uuid}', 'show') ->name('show') ->whereUuid('user_uuid'); Route::patch('/{user_uuid}/update', 'update') ->name('update') ->whereUuid('user_uuid'); + Route::delete('/{user_uuid}', 'destroy') + ->name('destroy') + ->middleware('role:admin') + ->whereUuid('user_uuid'); }); + Route::controller(\App\Http\Controllers\RoleController::class) + ->prefix('/roles') + ->as('roles.') + ->middleware('role:admin') + ->group(static function (): void { + Route::get('/', 'index') + ->name('index'); + Route::post('/', 'store') + ->name('store'); + Route::get('/{role_uuid}', 'show') + ->name('show') + ->whereUuid('role_uuid'); + Route::patch('/{role_uuid}/update', 'update') + ->name('update') + ->whereUuid('role_uuid'); + Route::delete('/{role_uuid}', 'destroy') + ->name('destroy') + ->whereUuid('role_uuid'); + }); }); diff --git a/src/tests/Feature/RoleControllerTest.php b/src/tests/Feature/RoleControllerTest.php new file mode 100644 index 0000000..98ac000 --- /dev/null +++ b/src/tests/Feature/RoleControllerTest.php @@ -0,0 +1,166 @@ +createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + Role::factory()->count(3)->create(); + + $response = actingAs($admin) + ->get(route('roles.index')); + + $response->assertOk(); + $response->assertViewIs('pages.role.index'); + $response->assertViewHas('data'); + + $viewData = $response->viewData('data'); + expect($viewData->total)->toBe(5); // 3 factory + 2 seeder (admin, user) +}); + +it('обычный пользователь не может просматривать список ролей', function () { + $user = User::factory()->createOne(); + + $response = actingAs($user) + ->get(route('roles.index')); + + $response->assertStatus(403); +}); + +// ========== Show ========== + +it('администратор может просматривать роль', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + $role = Role::factory()->createOne(); + + $response = actingAs($admin) + ->get(route('roles.show', ['role_uuid' => $role->uuid])); + + $response->assertOk(); + $response->assertViewIs('pages.role.show'); + $response->assertViewHas('data'); + + $viewData = $response->viewData('data'); + expect($viewData->uuid)->toBe($role->uuid->toString()); + expect($viewData->name)->toBe($role->name); +}); + +// ========== Store ========== + +it('администратор может создать роль', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + + $response = actingAs($admin) + ->postJson(route('roles.store'), [ + 'name' => 'Moderator', + 'code' => 'moderator', + 'description' => 'Moderator role', + ]); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $this->assertDatabaseHas('roles', [ + 'code' => 'moderator', + 'name' => 'Moderator', + ]); +}); + +it('обычный пользователь не может создать роль', function () { + $user = User::factory()->createOne(); + + $response = actingAs($user) + ->postJson(route('roles.store'), [ + 'name' => 'Moderator', + 'code' => 'moderator', + 'description' => 'Moderator role', + ]); + + $response->assertStatus(403); +}); + +it('создание роли требует уникальный code', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + Role::factory()->createOne(['code' => 'moderator']); + + $response = actingAs($admin) + ->postJson(route('roles.store'), [ + 'name' => 'Moderator', + 'code' => 'moderator', + 'description' => 'Moderator role', + ]); + + $response->assertStatus(422); +}); + +// ========== Update ========== + +it('администратор может обновить роль', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(); + $admin->role_uuid = $adminRole->uuid; + $admin->save(); + $role = Role::factory()->createOne(['code' => 'moderator']); + + $response = actingAs($admin) + ->patchJson(route('roles.update', ['role_uuid' => $role->uuid]), [ + 'name' => 'Updated Moderator', + 'description' => 'Updated description', + ]); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $role->refresh(); + expect($role->name)->toBe('Updated Moderator'); + expect($role->description)->toBe('Updated description'); +}); + +// ========== Destroy ========== + +it('администратор может удалить роль без пользователей', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + $role = Role::factory()->createOne(); + + $response = actingAs($admin) + ->deleteJson(route('roles.destroy', ['role_uuid' => $role->uuid])); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $this->assertDatabaseMissing('roles', ['uuid' => $role->uuid]); +}); + +it('нельзя удалить роль с привязанными пользователями', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + + $response = actingAs($admin) + ->deleteJson(route('roles.destroy', ['role_uuid' => $adminRole->uuid])); + + $response->assertStatus(409); // Conflict +}); + +it('обычный пользователь не может удалить роль', function () { + $user = User::factory()->createOne(); + $role = Role::factory()->createOne(); + + $response = actingAs($user) + ->deleteJson(route('roles.destroy', ['role_uuid' => $role->uuid])); + + $response->assertStatus(403); +}); \ No newline at end of file diff --git a/src/tests/Feature/UserControllerTest.php b/src/tests/Feature/UserControllerTest.php index 3572a4b..3a19d62 100644 --- a/src/tests/Feature/UserControllerTest.php +++ b/src/tests/Feature/UserControllerTest.php @@ -3,13 +3,14 @@ declare(strict_types=1); use App\Models\User; +use App\Models\Role; use Illuminate\Foundation\Testing\RefreshDatabase; use function Pest\Laravel\actingAs; uses(RefreshDatabase::class); - +// ========== Show ========== it('получение профиля пользователя', function () { $user = User::factory()->create(); @@ -19,5 +20,215 @@ it('получение профиля пользователя', function () { $response->assertOk(); $response->assertViewIs('pages.user.show'); - $response->assertViewHas('data', $user); + $response->assertViewHas('data'); + + $viewData = $response->viewData('data'); + expect($viewData->uuid)->toBe($user->uuid->toString()); + expect($viewData->name)->toBe($user->name); + expect($viewData->email)->toBe($user->email); +}); + +// ========== Index ========== + +it('администратор может просматривать список пользователей', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + User::factory()->count(5)->create(); + + $response = actingAs($admin) + ->get(route('users.index')); + + $response->assertOk(); + $response->assertViewIs('pages.user.index'); + $response->assertViewHas('data'); + + $viewData = $response->viewData('data'); + expect($viewData->total)->toBe(6); // 5 + 1 admin + expect($viewData->users)->toHaveCount(6); +}); + +it('обычный пользователь не может просматривать список пользователей', function () { + $user = User::factory()->createOne(); + + $response = actingAs($user) + ->get(route('users.index')); + + $response->assertStatus(403); +}); + +it('список пользователей поддерживает поиск', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid, 'name' => 'Admin User']); + User::factory()->createOne(['name' => 'John Doe']); + User::factory()->createOne(['name' => 'Jane Smith']); + + $response = actingAs($admin) + ->get(route('users.index', ['search' => 'John'])); + + $response->assertOk(); + $viewData = $response->viewData('data'); + expect($viewData->total)->toBe(1); +}); + +// ========== Store ========== + +it('администратор может создать пользователя', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + $userRole = Role::factory()->createOne(['code' => 'user']); + + $response = actingAs($admin) + ->postJson(route('users.store'), [ + 'name' => 'New User', + 'email' => 'newuser@example.com', + 'password' => 'password123', + 'role_uuid' => $userRole->uuid->toString(), + ]); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $this->assertDatabaseHas('users', [ + 'email' => 'newuser@example.com', + 'name' => 'New User', + 'role_uuid' => $userRole->uuid->toString(), + ]); +}); + +it('обычный пользователь не может создать пользователя', function () { + $user = User::factory()->createOne(); + $userRole = Role::factory()->createOne(['code' => 'user']); + + $response = actingAs($user) + ->postJson(route('users.store'), [ + 'name' => 'New User', + 'email' => 'newuser@example.com', + 'password' => 'password123', + 'role_uuid' => $userRole->uuid->toString(), + ]); + + $response->assertStatus(403); +}); + +it('создание пользователя требует уникальный email', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + $userRole = Role::factory()->createOne(['code' => 'user']); + + User::factory()->createOne(['email' => 'existing@example.com']); + + $response = actingAs($admin) + ->postJson(route('users.store'), [ + 'name' => 'New User', + 'email' => 'existing@example.com', + 'password' => 'password123', + 'role_uuid' => $userRole->uuid->toString(), + ]); + + $response->assertStatus(422); +}); + +// ========== Destroy ========== + +it('администратор может удалить пользователя', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + $targetUser = User::factory()->createOne(); + + $response = actingAs($admin) + ->deleteJson(route('users.destroy', ['user_uuid' => $targetUser->uuid])); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $this->assertDatabaseMissing('users', ['uuid' => $targetUser->uuid]); +}); + +it('администратор не может удалить самого себя', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + + $response = actingAs($admin) + ->deleteJson(route('users.destroy', ['user_uuid' => $admin->uuid])); + + $response->assertStatus(403); +}); + +it('обычный пользователь не может удалить пользователя', function () { + $user = User::factory()->createOne(); + $targetUser = User::factory()->createOne(); + + $response = actingAs($user) + ->deleteJson(route('users.destroy', ['user_uuid' => $targetUser->uuid])); + + $response->assertStatus(403); +}); + +// ========== Update ========== + +it('обычный пользователь не может редактировать другого пользователя', function () { + $user = User::factory()->createOne(); + $targetUser = User::factory()->createOne(); + + $response = actingAs($user) + ->patchJson(route('users.update', ['user_uuid' => $targetUser->uuid]), [ + 'name' => 'Новое имя', + 'email' => 'new@example.com' + ]); + + $response->assertStatus(403); +}); + +it('администратор может редактировать любого пользователя', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + $targetUser = User::factory()->createOne(); + + $response = actingAs($admin) + ->patchJson(route('users.update', ['user_uuid' => $targetUser->uuid]), [ + 'name' => 'Новое имя', + 'email' => 'new@example.com' + ]); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $targetUser->refresh(); + expect($targetUser->name)->toBe('Новое имя'); + expect($targetUser->email)->toBe('new@example.com'); +}); + +it('пользователь может редактировать свои данные', function () { + $user = User::factory()->createOne(); + + $response = actingAs($user) + ->patchJson(route('users.update', ['user_uuid' => $user->uuid]), [ + 'name' => 'Новое имя', + 'email' => 'new@example.com' + ]); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $user->refresh(); + expect($user->name)->toBe('Новое имя'); + expect($user->email)->toBe('new@example.com'); +}); + +it('администратор может изменить роль пользователя', function () { + $adminRole = Role::factory()->createOne(['code' => 'admin']); + $userRole = Role::factory()->createOne(['code' => 'user']); + $admin = User::factory()->createOne(['role_uuid' => $adminRole->uuid]); + $targetUser = User::factory()->createOne(['role_uuid' => $userRole->uuid]); + + $response = actingAs($admin) + ->patchJson(route('users.update', ['user_uuid' => $targetUser->uuid]), [ + 'role_uuid' => $adminRole->uuid->toString() + ]); + + $response->assertOk(); + $response->assertJson(['success' => true]); + + $targetUser->refresh(); + expect($targetUser->role_uuid)->toBe($adminRole->uuid->toString()); });