пользователи

This commit is contained in:
Toy Rik 2026-06-09 14:51:59 +03:00
parent 8c46816ff8
commit c769b7aafe
39 changed files with 1555 additions and 38 deletions

View File

@ -0,0 +1,34 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Actions;
use App\Domain\Role\Data\DestroyRequest;
use App\Domain\Role\Repositories\RoleRepository;
use App\Domain\Shared\Exceptions\AppException;
use Symfony\Component\HttpFoundation\Response;
class DestroyAction
{
public function __construct(
private RoleRepository $roleRepository,
) {
}
public function execute(DestroyRequest $request): void
{
$role = $this->roleRepository->whereUuid($request->role_uuid)->firstOrFail();
// Нельзя удалить роль, если к ней привязаны пользователи
if ($role->users()->count() > 0) {
AppException::new(
'role_in_use',
'Нельзя удалить роль, к которой привязаны пользователи',
Response::HTTP_CONFLICT
);
}
$role->delete();
}
}

View File

@ -0,0 +1,31 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Actions;
use App\Domain\Role\Data\IndexRequest;
use App\Domain\Role\Data\IndexResponseData;
use App\Domain\Role\Repositories\RoleRepository;
class IndexAction
{
public function __construct(
private RoleRepository $roleRepository,
) {
}
public function execute(IndexRequest $request): IndexResponseData
{
$query = $this->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);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Actions;
use App\Domain\Role\Data\ShowRequest;
use App\Domain\Role\Data\ShowResponseData;
use App\Domain\Role\Repositories\RoleRepository;
class ShowAction
{
public function __construct(
private RoleRepository $roleRepository,
) {
}
public function execute(ShowRequest $request): ShowResponseData
{
$result = $this->roleRepository->whereUuid($request->role_uuid)->firstOrFail();
return ShowResponseData::fromModel($result);
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Actions;
use App\Domain\Role\Data\StoreRequest;
use App\Domain\Role\Repositories\RoleRepository;
use App\Models\Role;
class StoreAction
{
public function __construct(
private RoleRepository $roleRepository,
) {
}
public function execute(StoreRequest $request): Role
{
return $this->roleRepository->create([
'name' => $request->name,
'code' => $request->code,
'description' => $request->description,
]);
}
}

View File

@ -0,0 +1,22 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Actions;
use App\Domain\Role\Data\UpdateRequest;
use App\Domain\Role\Repositories\RoleRepository;
class UpdateAction
{
public function __construct(
private RoleRepository $roleRepository,
) {
}
public function execute(UpdateRequest $request): void
{
$role = $this->roleRepository->whereUuid($request->role_uuid)->firstOrFail();
$role->update($request->getFilledFields());
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use Spatie\LaravelData\Attributes\FromRouteParameter;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class DestroyRequest extends Data
{
public function __construct(
#[StringType, FromRouteParameter('role_uuid'), Exists('roles', 'uuid'), Required]
public readonly string $role_uuid,
) {
}
}

View File

@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\IntegerType;
use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Nullable;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class IndexRequest extends Data
{
public function __construct(
#[IntegerType, Min(1), Nullable]
public readonly ?int $page = 1,
#[IntegerType, Min(1), Nullable]
public readonly ?int $per_page = 15,
#[StringType, Nullable]
public readonly ?string $sort_by = 'name',
#[StringType, Nullable]
public readonly ?string $sort_dir = 'asc',
) {
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use App\Models\Role;
use Illuminate\Pagination\LengthAwarePaginator;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
class IndexResponseData extends Data
{
public function __construct(
/** @var DataCollection<array-key, IndexRoleItemData> */
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(),
);
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use App\Models\Role;
use Spatie\LaravelData\Data;
class IndexRoleItemData extends Data
{
public function __construct(
public readonly string $uuid,
public readonly string $name,
public readonly string $code,
public readonly string $description,
public readonly int $users_count,
public readonly string $created_at,
) {
}
public static function fromModel(Role $model): self
{
return new self(
uuid: $model->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(),
);
}
}

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use Spatie\LaravelData\Attributes\FromRouteParameter;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class ShowRequest extends Data
{
public function __construct(
#[StringType, FromRouteParameter('role_uuid'), Exists('roles', 'uuid'), Required]
public readonly string $role_uuid,
) {
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use App\Models\Role;
use Spatie\LaravelData\Data;
class ShowResponseData extends Data
{
public function __construct(
public readonly string $uuid,
public readonly string $name,
public readonly string $code,
public readonly string $description,
public readonly int $users_count,
public readonly string $created_at,
public readonly string $updated_at,
) {
}
public static function fromModel(Role $model): self
{
return new self(
uuid: $model->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(),
);
}
}

View File

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\Validation\Unique;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class StoreRequest extends Data
{
public function __construct(
#[StringType, Required]
public readonly string $name,
#[StringType, Required, Unique('roles', 'code')]
public readonly string $code,
#[StringType, Required]
public readonly string $description,
) {
}
}

View File

@ -0,0 +1,40 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Data;
use Spatie\LaravelData\Attributes\FromRouteParameter;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\Nullable;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\Validation\Unique;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class UpdateRequest extends Data
{
public function __construct(
#[StringType, Required, FromRouteParameter('role_uuid'), Exists('roles', 'uuid')]
public readonly string $role_uuid,
#[StringType, Nullable]
public readonly ?string $name,
#[StringType, Nullable, Unique('roles', 'code')]
public readonly ?string $code,
#[StringType, Nullable]
public readonly ?string $description,
) {
}
public function getFilledFields(): array
{
return array_filter([
'name' => $this->name,
'code' => $this->code,
'description' => $this->description,
], fn ($value) => $value !== null);
}
}

View File

@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
namespace App\Domain\Role\Repositories;
use App\Domain\Shared\Repositories\BaseRepository;
use App\Models\Role;
class RoleRepository extends BaseRepository
{
public string $model = Role::class;
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Actions;
use App\Domain\User\Data\DestroyRequest;
use App\Domain\User\Repositories\UserRepository;
use App\Domain\Shared\Exceptions\AppException;
use App\Models\User;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class DestroyAction
{
public function __construct(
private UserRepository $userRepository,
) {
}
public function execute(DestroyRequest $request): void
{
/** @var User|null $currentUser */
$currentUser = Auth::user();
if (!$currentUser) {
AppException::new('UNAUTHORIZED', 'UNAUTHORIZED', Response::HTTP_UNAUTHORIZED);
}
// Нельзя удалить самого себя
if ($currentUser->uuid->toString() === $request->user_uuid) {
AppException::new('self_delete', 'Нельзя удалить самого себя', Response::HTTP_FORBIDDEN);
}
$user = $this->userRepository->whereUuid($request->user_uuid)->firstOrFail();
$user->delete();
}
}

View File

@ -0,0 +1,43 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Actions;
use App\Domain\User\Data\IndexRequest;
use App\Domain\User\Data\IndexResponseData;
use App\Domain\User\Repositories\UserRepository;
class IndexAction
{
public function __construct(
private UserRepository $userRepository,
) {
}
public function execute(IndexRequest $request): IndexResponseData
{
$query = $this->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);
}
}

View File

@ -0,0 +1,28 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Actions;
use App\Domain\User\Data\StoreRequest;
use App\Domain\User\Repositories\UserRepository;
use App\Models\User;
class StoreAction
{
public function __construct(
private UserRepository $userRepository,
) {
}
public function execute(StoreRequest $request): User
{
return $this->userRepository->create([
'name' => $request->name,
'email' => $request->email,
'password' => bcrypt($request->password),
'role_uuid' => $request->role_uuid,
'email_verified_at' => now(),
]);
}
}

View File

@ -6,7 +6,10 @@ namespace App\Domain\User\Actions;
use App\Domain\User\Data\UpdateRequest; use App\Domain\User\Data\UpdateRequest;
use App\Domain\User\Repositories\UserRepository; 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 class UpdateAction
{ {
@ -18,6 +21,26 @@ class UpdateAction
public function execute(UpdateRequest $request) 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 = $this->userRepository->whereUuid($request->user_uuid)->firstOrFail();
$user->update($request->getFilledFields()); $user->update($request->getFilledFields());
} }

View File

@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use Spatie\LaravelData\Attributes\FromRouteParameter;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class DestroyRequest extends Data
{
public function __construct(
#[StringType, FromRouteParameter('user_uuid'), Exists('users', 'uuid'), Required]
public readonly string $user_uuid,
) {
}
}

View File

@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\IntegerType;
use Spatie\LaravelData\Attributes\Validation\Min;
use Spatie\LaravelData\Attributes\Validation\Nullable;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class IndexRequest extends Data
{
public function __construct(
#[IntegerType, Min(1), Nullable]
public readonly ?int $page = 1,
#[IntegerType, Min(1), Nullable]
public readonly ?int $per_page = 15,
#[StringType, Nullable]
public readonly ?string $search = null,
#[StringType, Nullable]
public readonly ?string $role = null,
#[StringType, Nullable]
public readonly ?string $sort_by = 'created_at',
#[StringType, Nullable]
public readonly ?string $sort_dir = 'desc',
) {
}
}

View File

@ -0,0 +1,38 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use App\Models\User;
use Illuminate\Pagination\LengthAwarePaginator;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\DataCollection;
class IndexResponseData extends Data
{
public function __construct(
/** @var DataCollection<array-key, IndexUserItemData> */
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(),
);
}
}

View File

@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use App\Domain\User\Data\ValueObjects\RoleData;
use App\Models\User;
use Spatie\LaravelData\Data;
class IndexUserItemData extends Data
{
public function __construct(
public readonly string $uuid,
public readonly string $name,
public readonly string $email,
public readonly bool $email_verified,
public readonly RoleData $role,
public readonly string $created_at,
) {
}
public static function fromModel(User $model): self
{
return new self(
uuid: $model->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(),
);
}
}

View File

@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace App\Domain\User\Data;
use Spatie\LaravelData\Attributes\MapName;
use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\Validation\Unique;
use Spatie\LaravelData\Attributes\Validation\Uuid as UuidValidation;
use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper;
#[MapName(SnakeCaseMapper::class)]
class StoreRequest extends Data
{
public function __construct(
#[StringType, Required]
public readonly string $name,
#[StringType, Email, Required, Unique('users', 'email')]
public readonly string $email,
#[StringType, Required]
public readonly string $password,
#[
UuidValidation,
Required,
Exists('roles', 'uuid')
]
public readonly string $role_uuid,
) {
}
}

View File

@ -10,6 +10,8 @@ use Spatie\LaravelData\Attributes\Validation\Email;
use Spatie\LaravelData\Attributes\Validation\Exists; use Spatie\LaravelData\Attributes\Validation\Exists;
use Spatie\LaravelData\Attributes\Validation\StringType; use Spatie\LaravelData\Attributes\Validation\StringType;
use Spatie\LaravelData\Attributes\Validation\Nullable; use Spatie\LaravelData\Attributes\Validation\Nullable;
use Spatie\LaravelData\Attributes\Validation\Required;
use Spatie\LaravelData\Attributes\Validation\Uuid as UuidValidation;
use Spatie\LaravelData\Data; use Spatie\LaravelData\Data;
use Spatie\LaravelData\Mappers\SnakeCaseMapper; use Spatie\LaravelData\Mappers\SnakeCaseMapper;
@ -17,7 +19,12 @@ use Spatie\LaravelData\Mappers\SnakeCaseMapper;
class UpdateRequest extends Data class UpdateRequest extends Data
{ {
public function __construct( public function __construct(
#[StringType, FromRouteParameter('user_uuid'), Exists('users', 'uuid')] #[
UuidValidation,
FromRouteParameter('user_uuid'),
Exists('users', 'uuid'),
Required
]
public readonly string $user_uuid, public readonly string $user_uuid,
#[StringType, Nullable] #[StringType, Nullable]
public readonly ?string $name, public readonly ?string $name,
@ -25,6 +32,12 @@ class UpdateRequest extends Data
public readonly ?string $email, public readonly ?string $email,
#[StringType, Nullable] #[StringType, Nullable]
public readonly ?string $password, public readonly ?string $password,
#[
UuidValidation,
Nullable,
Exists('roles', 'uuid')
]
public readonly ?string $role_uuid,
) { ) {
} }
@ -34,6 +47,7 @@ class UpdateRequest extends Data
$fields = [ $fields = [
'name' => $this->name, 'name' => $this->name,
'email' => $this->email, 'email' => $this->email,
'role_uuid' => $this->role_uuid,
'password' => $this->password ? bcrypt($this->password) : null, 'password' => $this->password ? bcrypt($this->password) : null,
]; ];

View File

@ -12,13 +12,33 @@ class MenuHelper
[ [
'icon' => 'dashboard', 'icon' => 'dashboard',
'name' => 'Dashboard', 'name' => 'Dashboard',
'path' => route('dashboard') 'path' => route('dashboard'),
], ],
[ [
'icon' => 'user-profile', 'icon' => 'user-profile',
'name' => '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', 'title' => 'Menu',
'items' => self::getMainNavItems(), 'items' => self::getMainNavItems(),
], ],
[
'title' => 'Administration',
'items' => self::getAdminNavItems(),
],
[ [
'title' => 'Others', '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 = [ $icons = [
'dashboard' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>Dashboard SVG Icon</title><path fill="currentColor" d="M24 21h2v5h-2zm-4-5h2v10h-2zm-9 10a5.006 5.006 0 0 1-5-5h2a3 3 0 1 0 3-3v-2a5 5 0 0 1 0 10"/><path fill="currentColor" d="M28 2H4a2.002 2.002 0 0 0-2 2v24a2.002 2.002 0 0 0 2 2h24a2.003 2.003 0 0 0 2-2V4a2.002 2.002 0 0 0-2-2m0 9H14V4h14ZM12 4v7H4V4ZM4 28V13h24l.002 15Z"/></svg>', 'dashboard' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>Dashboard SVG Icon</title><path fill="currentColor" d="M24 21h2v5h-2zm-4-5h2v10h-2zm-9 10a5.006 5.006 0 0 1-5-5h2a3 3 0 1 0 3-3v-2a5 5 0 0 1 0 10"/><path fill="currentColor" d="M28 2H4a2.002 2.002 0 0 0-2 2v24a2.002 2.002 0 0 0 2 2h24a2.003 2.003 0 0 0 2-2V4a2.002 2.002 0 0 0-2-2m0 9H14V4h14ZM12 4v7H4V4ZM4 28V13h24l.002 15Z"/></svg>',
'user-profile' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>User-avatar SVG Icon</title><path fill="currentColor" d="M16 8a5 5 0 1 0 5 5a5 5 0 0 0-5-5m0 8a3 3 0 1 1 3-3a3.003 3.003 0 0 1-3 3"/><path fill="currentColor" d="M16 2a14 14 0 1 0 14 14A14.016 14.016 0 0 0 16 2m-6 24.377V25a3.003 3.003 0 0 1 3-3h6a3.003 3.003 0 0 1 3 3v1.377a11.899 11.899 0 0 1-12 0m13.993-1.451A5.002 5.002 0 0 0 19 20h-6a5.002 5.002 0 0 0-4.992 4.926a12 12 0 1 1 15.985 0"/></svg>' 'user-profile' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>User-avatar SVG Icon</title><path fill="currentColor" d="M16 8a5 5 0 1 0 5 5a5 5 0 0 0-5-5m0 8a3 3 0 1 1 3-3a3.003 3.003 0 0 1-3 3"/><path fill="currentColor" d="M16 2a14 14 0 1 0 14 14A14.016 14.016 0 0 0 16 2m-6 24.377V25a3.003 3.003 0 0 1 3-3h6a3.003 3.003 0 0 1 3 3v1.377a11.899 11.899 0 0 1-12 0m13.993-1.451A5.002 5.002 0 0 0 19 20h-6a5.002 5.002 0 0 0-4.992 4.926a12 12 0 1 1 15.985 0"/></svg>',
'users' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>Users SVG Icon</title><path fill="currentColor" d="M16 17a5 5 0 1 0-5-5a5 5 0 0 0 5 5m0-8a3 3 0 1 1-3 3a3 3 0 0 1 3-3"/><path fill="currentColor" d="M16 19c-4.4 0-8 2.3-8 5v2h16v-2c0-2.7-3.6-5-8-5m-5.6 5c.9-1.1 3.1-3 5.6-3s4.7 1.9 5.6 3Z"/><path fill="currentColor" d="M30 17a5 5 0 1 0-5-5a5 5 0 0 0 5 5m-2 2c-1.2 0-2.4.3-3.4.8a8.5 8.5 0 0 1 2.6 2.2H30v-2c0-.6-.5-1-2-1"/><path fill="currentColor" d="M2 17a5 5 0 1 0 5-5a5 5 0 0 0-5 5m2 2c-1.5 0-2 .4-2 1v2h2.8a8.5 8.5 0 0 1 2.6-2.2A6.5 6.5 0 0 0 4 19"/></svg>',
'roles' => '<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32"><title>Roles SVG Icon</title><path fill="currentColor" d="M16 2a14 14 0 1 0 14 14A14.016 14.016 0 0 0 16 2m0 4a5 5 0 1 1-5 5a5 5 0 0 1 5-5m0 20a10 10 0 0 1-8.4-4.4c.1-2.8 5.6-4.3 8.4-4.3s8.3 1.5 8.4 4.3A10 10 0 0 1 16 26"/></svg>',
]; ];
return $icons[$iconName] ?? '<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/></svg>'; return $icons[$iconName] ?? '<svg width="1em" height="1em" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z" fill="currentColor"/></svg>';
} }

View File

@ -4,7 +4,51 @@ declare(strict_types=1);
namespace App\Http\Controllers; 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 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']);
}
} }

View File

@ -4,17 +4,25 @@ declare(strict_types=1);
namespace App\Http\Controllers; 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\ShowAction;
use App\Domain\User\Actions\StoreAction;
use App\Domain\User\Actions\UpdateAction; 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\ShowRequest;
use App\Domain\User\Data\StoreRequest;
use App\Domain\User\Data\UpdateRequest; use App\Domain\User\Data\UpdateRequest;
use Illuminate\Contracts\View\View; use Illuminate\Contracts\View\View;
use Illuminate\Http\JsonResponse;
class UserController extends Controller 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 public function show(ShowRequest $request, ShowAction $action): View
@ -23,10 +31,24 @@ class UserController extends Controller
return view('pages.user.show', ['data' => $data]); 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']); 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']);
}
} }

View File

@ -33,13 +33,10 @@ class CheckRoleMiddleware
AppException::new('UNAUTHORIZED', 'UNAUTHORIZED', Response::HTTP_UNAUTHORIZED); AppException::new('UNAUTHORIZED', 'UNAUTHORIZED', Response::HTTP_UNAUTHORIZED);
} }
$userRole = $user->load('role')->role(); $user->load('role');
$hasRole = $userRole->whereIn('code', $roles) if (!$user->role || !in_array($user->role->code, $roles, true)) {
->count(); AppException::new('forbidden', 'Недостаточно прав', Response::HTTP_FORBIDDEN);
if (!$hasRole) {
AppException::new('forbidden', 'Недостаточно прав');
} }
return $next($request); return $next($request);

View File

@ -4,6 +4,8 @@ declare(strict_types=1);
namespace App\Providers; namespace App\Providers;
use App\Domain\Role\Repositories\RoleRepository;
use App\Domain\User\Repositories\UserRepository;
use Illuminate\Support\ServiceProvider; use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider class AppServiceProvider extends ServiceProvider
@ -13,7 +15,13 @@ class AppServiceProvider extends ServiceProvider
*/ */
public function register(): void public function register(): void
{ {
// $this->app->singleton(UserRepository::class, function () {
return new UserRepository();
});
$this->app->singleton(RoleRepository::class, function () {
return new RoleRepository();
});
} }
/** /**

View File

@ -50,7 +50,7 @@ class FortifyServiceProvider extends ServiceProvider
// Custom routes defined specifically // Custom routes defined specifically
Fortify::registerView(function () { Fortify::registerView(function () {
return view('auth.register'); return view('pages.auth.register');
}); });
Fortify::loginView(function () { Fortify::loginView(function () {
@ -58,23 +58,23 @@ class FortifyServiceProvider extends ServiceProvider
}); });
Fortify::verifyEmailView(function () { Fortify::verifyEmailView(function () {
return view('auth.verify-mail'); return view('pages.auth.verify-mail');
}); });
Fortify::requestPasswordResetLinkView(function () { Fortify::requestPasswordResetLinkView(function () {
return view('auth.forgot-password'); return view('pages.auth.forgot-password');
}); });
Fortify::resetPasswordView(function (Request $request) { Fortify::resetPasswordView(function (Request $request) {
return view('auth.reset-password', ['request' => $request]); return view('pages.auth.reset-password', ['request' => $request]);
}); });
Fortify::confirmPasswordView(function () { Fortify::confirmPasswordView(function () {
return view('auth.confirm-password'); return view('pages.auth.confirm-password');
}); });
Fortify::twoFactorChallengeView(function () { Fortify::twoFactorChallengeView(function () {
return view('auth.two-factor-challange'); return view('pages.auth.two-factor-challange');
}); });
} }
} }

View File

@ -43,4 +43,12 @@ class UserFactory extends Factory
'email_verified_at' => null, 'email_verified_at' => null,
]); ]);
} }
public function hasRole(Role|Factory|null $role = null): self
{
return $this->for(
$role ?? Role::factory(),
'role'
);
}
} }

View File

@ -3,6 +3,7 @@
name: '{{ $user->name }}', name: '{{ $user->name }}',
email: '{{ $user->email }}', email: '{{ $user->email }}',
password: '{{ $user->password ?? null }}', password: '{{ $user->password ?? null }}',
role_uuid: '{{ $user->role->uuid ?? null }}',
errors: {}, errors: {},
saveProfile() { saveProfile() {
// Очищаем предыдущие ошибки // Очищаем предыдущие ошибки
@ -22,6 +23,10 @@
data.email = this.email; data.email = this.email;
} }
if (this.role_uuid) {
data.role_uuid = this.role_uuid;
}
if (this.password) { if (this.password) {
data.password = this.password; data.password = this.password;
} }
@ -187,10 +192,13 @@
@if(auth()->user()->role->code === 'admin') @if(auth()->user()->role->code === 'admin')
<div class="col-span-2"> <div class="col-span-2">
<label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400"> <label class="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-400">
Bio Role
</label> </label>
<input type="text" value="{{ $user->role->name }}" <select x-model="roleUuid" class="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800">
class="dark:bg-dark-900 h-11 w-full appearance-none rounded-lg border border-gray-300 bg-transparent bg-none px-4 py-2.5 text-sm text-gray-800 shadow-theme-xs placeholder:text-gray-400 focus:border-brand-300 focus:outline-hidden focus:ring-3 focus:ring-brand-500/10 dark:border-gray-700 dark:bg-gray-900 dark:text-white/90 dark:placeholder:text-white/30 dark:focus:border-brand-800" /> @foreach(App\Models\Role::all() as $role)
<option value="{{ $role->uuid }}" @if($user->role->uuid == $role->uuid) selected @endif>{{ $role->name }}</option>
@endforeach
</select>
</div> </div>
@endif @endif
</div> </div>

View File

@ -1,5 +1,69 @@
@extends ('layouts.app') @extends ('layouts.app')
@section ('content') @section ('content')
<h1>dashboard</h1> <x-common.page-breadcrumb pageTitle="{{ __('Dashboard') }}" />
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2 lg:grid-cols-3">
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Total Users') }}</p>
<p class="mt-1 text-2xl font-semibold text-gray-800 dark:text-white/90">{{ \App\Models\User::count() }}</p>
</div>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-500/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-blue-600 dark:text-blue-400">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/>
<circle cx="9" cy="7" r="4"/>
<path d="M23 21v-2a4 4 0 0 0-3-3.87"/>
<path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
</div>
<div class="mt-4">
<a href="{{ route('users.index') }}" class="text-sm text-blue-600 hover:underline dark:text-blue-400">
{{ __('Manage Users') }}
</a>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Total Roles') }}</p>
<p class="mt-1 text-2xl font-semibold text-gray-800 dark:text-white/90">{{ \App\Models\Role::count() }}</p>
</div>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-purple-100 dark:bg-purple-500/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-purple-600 dark:text-purple-400">
<rect x="3" y="11" width="18" height="11" rx="2" ry="2"/>
<path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg>
</div>
</div>
<div class="mt-4">
<a href="{{ route('roles.index') }}" class="text-sm text-blue-600 hover:underline dark:text-blue-400">
{{ __('Manage Roles') }}
</a>
</div>
</div>
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-500 dark:text-gray-400">{{ __('Verified Users') }}</p>
<p class="mt-1 text-2xl font-semibold text-gray-800 dark:text-white/90">{{ \App\Models\User::whereNotNull('email_verified_at')->count() }}</p>
</div>
<div class="flex h-12 w-12 items-center justify-center rounded-full bg-green-100 dark:bg-green-500/10">
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" class="text-green-600 dark:text-green-400">
<polyline points="9 11 12 14 22 4"/>
<path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>
</svg>
</div>
</div>
<div class="mt-4">
<p class="text-sm text-gray-500 dark:text-gray-400">
{{ round((\App\Models\User::whereNotNull('email_verified_at')->count() / max(\App\Models\User::count(), 1)) * 100) }}% {{ __('verified') }}
</p>
</div>
</div>
</div>
@endsection @endsection

View File

@ -0,0 +1,91 @@
@extends ('layouts.app')
@section ('content')
<x-common.page-breadcrumb pageTitle="{{ __('Roles') }}" />
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white/90">{{ __('Roles List') }}</h3>
@if(auth()->user()?->role?->code === 'admin')
<button type="button" class="btn btn-primary" onclick="alert('Create role modal')">
+ {{ __('Create Role') }}
</button>
@endif
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Name') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Code') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Description') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Users') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Created') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody>
@forelse($data->roles as $role)
<tr class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="py-3 px-4 text-sm font-medium text-gray-800 dark:text-white/90">
{{ $role->name }}
</td>
<td class="py-3 px-4 text-sm">
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
{{ $role->code }}
</span>
</td>
<td class="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{{ $role->description }}</td>
<td class="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{{ $role->users_count }}</td>
<td class="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{{ $role->created_at }}</td>
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-2">
@if(auth()->user()?->role?->code === 'admin' && $role->users_count === 0)
<form method="POST" action="{{ route('roles.destroy', ['role_uuid' => $role->uuid]) }}"
onsubmit="return confirm('{{ __('Are you sure?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline dark:text-red-400">
{{ __('Delete') }}
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{{ __('No roles found') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($data->last_page > 1)
<div class="mt-5 flex items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ __('Page') }} {{ $data->current_page }} {{ __('of') }} {{ $data->last_page }}
({{ $data->total }} {{ __('total') }})
</div>
<div class="flex items-center gap-2">
@if($data->current_page > 1)
<a href="{{ route('roles.index', ['page' => $data->current_page - 1]) }}"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
{{ __('Previous') }}
</a>
@endif
@if($data->current_page < $data->last_page)
<a href="{{ route('roles.index', ['page' => $data->current_page + 1]) }}"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
{{ __('Next') }}
</a>
@endif
</div>
</div>
@endif
</div>
@endsection

View File

@ -0,0 +1,39 @@
@extends ('layouts.app')
@section ('content')
<x-common.page-breadcrumb pageTitle="{{ __('Role Details') }}" />
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<div class="mb-5">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white/90">{{ $data->name }}</h3>
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-400 mt-1">
{{ $data->code }}
</span>
</div>
<div class="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Description') }}</label>
<p class="mt-1 text-sm text-gray-800 dark:text-white/90">{{ $data->description }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Users Count') }}</label>
<p class="mt-1 text-sm text-gray-800 dark:text-white/90">{{ $data->users_count }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Created') }}</label>
<p class="mt-1 text-sm text-gray-800 dark:text-white/90">{{ $data->created_at }}</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Updated') }}</label>
<p class="mt-1 text-sm text-gray-800 dark:text-white/90">{{ $data->updated_at }}</p>
</div>
</div>
<div class="mt-6">
<a href="{{ route('roles.index') }}" class="text-blue-600 hover:underline dark:text-blue-400">
{{ __('Back to roles') }}
</a>
</div>
</div>
@endsection

View File

@ -1,5 +1,103 @@
@extends ('layouts.app') @extends ('layouts.app')
@section ('content') @section ('content')
<h1>Users index</h1> <x-common.page-breadcrumb pageTitle="{{ __('Users') }}" />
<div class="rounded-2xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-white/[0.03] lg:p-6">
<div class="flex items-center justify-between mb-5">
<h3 class="text-lg font-semibold text-gray-800 dark:text-white/90">{{ __('Users List') }}</h3>
@if(auth()->user()?->role?->code === 'admin')
<button type="button" class="btn btn-primary" onclick="alert('Create user modal')">
+ {{ __('Create User') }}
</button>
@endif
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead>
<tr class="border-b border-gray-200 dark:border-gray-700">
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Name') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Email') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Role') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Verified') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Created') }}</th>
<th class="py-3 px-4 text-sm font-medium text-gray-500 dark:text-gray-400">{{ __('Actions') }}</th>
</tr>
</thead>
<tbody>
@forelse($data->users as $user)
<tr class="border-b border-gray-100 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="py-3 px-4 text-sm text-gray-800 dark:text-white/90">
<a href="{{ route('users.show', ['user_uuid' => $user->uuid]) }}" class="text-blue-600 hover:underline">
{{ $user->name }}
</a>
</td>
<td class="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{{ $user->email }}</td>
<td class="py-3 px-4 text-sm">
<span class="inline-flex items-center rounded-full bg-blue-50 px-2 py-1 text-xs font-medium text-blue-700 dark:bg-blue-500/10 dark:text-blue-400">
{{ $user->role->name }}
</span>
</td>
<td class="py-3 px-4 text-sm">
@if($user->email_verified)
<span class="text-green-600 dark:text-green-400"></span>
@else
<span class="text-red-600 dark:text-red-400"></span>
@endif
</td>
<td class="py-3 px-4 text-sm text-gray-600 dark:text-gray-400">{{ $user->created_at }}</td>
<td class="py-3 px-4 text-sm">
<div class="flex items-center gap-2">
<a href="{{ route('users.show', ['user_uuid' => $user->uuid]) }}"
class="text-blue-600 hover:underline dark:text-blue-400">
{{ __('View') }}
</a>
@if(auth()->user()?->role?->code === 'admin' && auth()->user()?->uuid !== $user->uuid)
<form method="POST" action="{{ route('users.destroy', ['user_uuid' => $user->uuid]) }}"
onsubmit="return confirm('{{ __('Are you sure?') }}')">
@csrf
@method('DELETE')
<button type="submit" class="text-red-600 hover:underline dark:text-red-400">
{{ __('Delete') }}
</button>
</form>
@endif
</div>
</td>
</tr>
@empty
<tr>
<td colspan="6" class="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
{{ __('No users found') }}
</td>
</tr>
@endforelse
</tbody>
</table>
</div>
@if($data->last_page > 1)
<div class="mt-5 flex items-center justify-between">
<div class="text-sm text-gray-500 dark:text-gray-400">
{{ __('Page') }} {{ $data->current_page }} {{ __('of') }} {{ $data->last_page }}
({{ $data->total }} {{ __('total') }})
</div>
<div class="flex items-center gap-2">
@if($data->current_page > 1)
<a href="{{ route('users.index', ['page' => $data->current_page - 1]) }}"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
{{ __('Previous') }}
</a>
@endif
@if($data->current_page < $data->last_page)
<a href="{{ route('users.index', ['page' => $data->current_page + 1]) }}"
class="rounded-lg border border-gray-200 px-3 py-1.5 text-sm hover:bg-gray-50 dark:border-gray-700 dark:hover:bg-gray-800">
{{ __('Next') }}
</a>
@endif
</div>
</div>
@endif
</div>
@endsection @endsection

View File

@ -13,6 +13,7 @@ Route::get('/', function () {
Route::middleware(['auth', 'verified'])->group(function () { Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/dashboard', [DashboardController::class, 'index']) Route::get('/dashboard', [DashboardController::class, 'index'])
->name('dashboard'); ->name('dashboard');
Route::controller(UserController::class) Route::controller(UserController::class)
->prefix('/users') ->prefix('/users')
->as('users.') ->as('users.')
@ -20,12 +21,38 @@ Route::middleware(['auth', 'verified'])->group(function () {
Route::get('/', 'index') Route::get('/', 'index')
->name('index') ->name('index')
->middleware('role:admin'); ->middleware('role:admin');
Route::post('/', 'store')
->name('store')
->middleware('role:admin');
Route::get('/{user_uuid}', 'show') Route::get('/{user_uuid}', 'show')
->name('show') ->name('show')
->whereUuid('user_uuid'); ->whereUuid('user_uuid');
Route::patch('/{user_uuid}/update', 'update') Route::patch('/{user_uuid}/update', 'update')
->name('update') ->name('update')
->whereUuid('user_uuid'); ->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');
});
}); });

View File

@ -0,0 +1,166 @@
<?php
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);
// ========== Index ==========
it('администратор может просматривать список ролей', function () {
$adminRole = Role::factory()->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);
});

View File

@ -3,13 +3,14 @@
declare(strict_types=1); declare(strict_types=1);
use App\Models\User; use App\Models\User;
use App\Models\Role;
use Illuminate\Foundation\Testing\RefreshDatabase; use Illuminate\Foundation\Testing\RefreshDatabase;
use function Pest\Laravel\actingAs; use function Pest\Laravel\actingAs;
uses(RefreshDatabase::class); uses(RefreshDatabase::class);
// ========== Show ==========
it('получение профиля пользователя', function () { it('получение профиля пользователя', function () {
$user = User::factory()->create(); $user = User::factory()->create();
@ -19,5 +20,215 @@ it('получение профиля пользователя', function () {
$response->assertOk(); $response->assertOk();
$response->assertViewIs('pages.user.show'); $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());
}); });