Skip to content

Commit e055f2e

Browse files
authored
Feature: Add admin panel (#871)
1 parent 60c3f4c commit e055f2e

File tree

87 files changed

+3390
-132
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+3390
-132
lines changed
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php
2+
3+
namespace HiEvents\Console\Commands;
4+
5+
use Exception;
6+
use HiEvents\DomainObjects\Enums\Role;
7+
use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface;
8+
use HiEvents\Repository\Interfaces\UserRepositoryInterface;
9+
use Illuminate\Console\Command;
10+
use Psr\Log\LoggerInterface;
11+
12+
class AssignSuperAdminCommand extends Command
13+
{
14+
protected $signature = 'user:make-superadmin {userId : The ID of the user to make a superadmin}';
15+
16+
protected $description = 'Assign SUPERADMIN role to a user. WARNING: This grants complete system access.';
17+
18+
public function __construct(
19+
private readonly UserRepositoryInterface $userRepository,
20+
private readonly AccountUserRepositoryInterface $accountUserRepository,
21+
private readonly LoggerInterface $logger,
22+
)
23+
{
24+
parent::__construct();
25+
}
26+
27+
public function handle(): int
28+
{
29+
$userId = $this->argument('userId');
30+
31+
$this->warn('⚠️ WARNING: This command will grant COMPLETE SYSTEM ACCESS to the user.');
32+
$this->warn('⚠️ SUPERADMIN users have unrestricted access to all accounts and data.');
33+
$this->newLine();
34+
35+
if (!$this->confirm('Are you sure you want to proceed?', false)) {
36+
$this->info('Operation cancelled.');
37+
return self::FAILURE;
38+
}
39+
40+
try {
41+
$user = $this->userRepository->findById((int)$userId);
42+
} catch (Exception $exception) {
43+
$this->error("Error finding user with ID: $userId" . " Message: " . $exception->getMessage());
44+
return self::FAILURE;
45+
}
46+
47+
$this->info("Found user: {$user->getFullName()} ({$user->getEmail()})");
48+
$this->newLine();
49+
50+
if (!$this->confirm('Confirm assigning SUPERADMIN role to this user?', false)) {
51+
$this->info('Operation cancelled.');
52+
return self::FAILURE;
53+
}
54+
55+
$accountUsers = $this->accountUserRepository->findWhere([
56+
'user_id' => $userId,
57+
]);
58+
59+
if ($accountUsers->isEmpty()) {
60+
$this->error('User is not associated with any accounts.');
61+
return self::FAILURE;
62+
}
63+
64+
$updatedCount = 0;
65+
foreach ($accountUsers as $accountUser) {
66+
if ($accountUser->getRole() === Role::SUPERADMIN->name) {
67+
$this->comment("User already has SUPERADMIN role for account ID: {$accountUser->getAccountId()}");
68+
continue;
69+
}
70+
71+
$this->accountUserRepository->updateWhere(
72+
attributes: [
73+
'role' => Role::SUPERADMIN->name,
74+
],
75+
where: [
76+
'id' => $accountUser->getId(),
77+
]
78+
);
79+
80+
$updatedCount++;
81+
82+
$this->logger->critical('SUPERADMIN role assigned via console command', [
83+
'user_id' => $userId,
84+
'user_email' => $user->getEmail(),
85+
'account_id' => $accountUser->getAccountId(),
86+
'previous_role' => $accountUser->getRole(),
87+
'command' => $this->signature,
88+
]);
89+
}
90+
91+
$this->newLine();
92+
$this->info("✓ Successfully assigned SUPERADMIN role to user across $updatedCount account(s).");
93+
$this->warn("⚠️ User {$user->getFullName()} now has COMPLETE SYSTEM ACCESS.");
94+
95+
$this->logger->critical('SUPERADMIN role assignment completed', [
96+
'user_id' => $userId,
97+
'accounts_updated' => $updatedCount,
98+
]);
99+
100+
return self::SUCCESS;
101+
}
102+
}

backend/app/DomainObjects/Enums/Role.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,15 @@ enum Role: string
66
{
77
use BaseEnum;
88

9+
case SUPERADMIN = 'SUPERADMIN';
910
case ADMIN = 'ADMIN';
1011
case ORGANIZER = 'ORGANIZER';
12+
13+
public static function getAssignableRoles(): array
14+
{
15+
return [
16+
self::ADMIN->value,
17+
self::ORGANIZER->value,
18+
];
19+
}
1120
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HiEvents\Http\Actions\Admin\Accounts;
6+
7+
use HiEvents\DomainObjects\Enums\Role;
8+
use HiEvents\Http\Actions\BaseAction;
9+
use HiEvents\Resources\Account\AdminAccountResource;
10+
use HiEvents\Services\Application\Handlers\Admin\DTO\GetAllAccountsDTO;
11+
use HiEvents\Services\Application\Handlers\Admin\GetAllAccountsHandler;
12+
use Illuminate\Http\JsonResponse;
13+
use Illuminate\Http\Request;
14+
15+
class GetAllAccountsAction extends BaseAction
16+
{
17+
public function __construct(
18+
private readonly GetAllAccountsHandler $handler,
19+
)
20+
{
21+
}
22+
23+
public function __invoke(Request $request): JsonResponse
24+
{
25+
$this->minimumAllowedRole(Role::SUPERADMIN);
26+
27+
$accounts = $this->handler->handle(new GetAllAccountsDTO(
28+
perPage: min((int)$request->query('per_page', 20), 100),
29+
search: $request->query('search'),
30+
));
31+
32+
return $this->resourceResponse(
33+
resource: AdminAccountResource::class,
34+
data: $accounts
35+
);
36+
}
37+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HiEvents\Http\Actions\Admin\Stats;
6+
7+
use HiEvents\DomainObjects\Enums\Role;
8+
use HiEvents\Http\Actions\BaseAction;
9+
use HiEvents\Services\Application\Handlers\Admin\GetAdminStatsHandler;
10+
use Illuminate\Http\JsonResponse;
11+
12+
class GetAdminStatsAction extends BaseAction
13+
{
14+
public function __construct(
15+
private readonly GetAdminStatsHandler $handler,
16+
)
17+
{
18+
}
19+
20+
public function __invoke(): JsonResponse
21+
{
22+
$this->minimumAllowedRole(Role::SUPERADMIN);
23+
24+
$stats = $this->handler->handle();
25+
26+
return $this->jsonResponse($stats->toArray());
27+
}
28+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HiEvents\Http\Actions\Admin\Users;
6+
7+
use HiEvents\DomainObjects\Enums\Role;
8+
use HiEvents\Http\Actions\BaseAction;
9+
use HiEvents\Resources\User\AdminUserResource;
10+
use HiEvents\Services\Application\Handlers\Admin\DTO\GetAllUsersDTO;
11+
use HiEvents\Services\Application\Handlers\Admin\GetAllUsersHandler;
12+
use Illuminate\Http\JsonResponse;
13+
use Illuminate\Http\Request;
14+
15+
class GetAllUsersAction extends BaseAction
16+
{
17+
public function __construct(
18+
private readonly GetAllUsersHandler $handler,
19+
)
20+
{
21+
}
22+
23+
public function __invoke(Request $request): JsonResponse
24+
{
25+
$this->minimumAllowedRole(Role::SUPERADMIN);
26+
27+
$users = $this->handler->handle(new GetAllUsersDTO(
28+
perPage: min((int)$request->query('per_page', 20), 100),
29+
search: $request->query('search'),
30+
));
31+
32+
return $this->resourceResponse(
33+
resource: AdminUserResource::class,
34+
data: $users
35+
);
36+
}
37+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HiEvents\Http\Actions\Admin\Users;
6+
7+
use HiEvents\DomainObjects\Enums\Role;
8+
use HiEvents\Http\Actions\Auth\BaseAuthAction;
9+
use HiEvents\Services\Application\Handlers\Admin\DTO\StartImpersonationDTO;
10+
use HiEvents\Services\Application\Handlers\Admin\StartImpersonationHandler;
11+
use Illuminate\Http\JsonResponse;
12+
use Illuminate\Http\Request;
13+
14+
class StartImpersonationAction extends BaseAuthAction
15+
{
16+
public function __construct(
17+
private readonly StartImpersonationHandler $handler,
18+
)
19+
{
20+
}
21+
22+
public function __invoke(Request $request, int $userId): JsonResponse
23+
{
24+
$this->minimumAllowedRole(Role::SUPERADMIN);
25+
26+
$this->validate($request, [
27+
'account_id' => 'required|exists:accounts,id'
28+
]);
29+
30+
$token = $this->handler->handle(new StartImpersonationDTO(
31+
userId: $userId,
32+
accountId: $request->input('account_id'),
33+
impersonatorId: $this->getAuthenticatedUser()->getId(),
34+
));
35+
36+
$response = $this->jsonResponse([
37+
'message' => __('Impersonation started'),
38+
'redirect_url' => '/manage/events',
39+
'token' => $token
40+
]);
41+
42+
return $this->addTokenToResponse($response, $token);
43+
}
44+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace HiEvents\Http\Actions\Admin\Users;
6+
7+
use HiEvents\Http\Actions\Auth\BaseAuthAction;
8+
use HiEvents\Services\Application\Handlers\Admin\DTO\StopImpersonationDTO;
9+
use HiEvents\Services\Application\Handlers\Admin\StopImpersonationHandler;
10+
use Illuminate\Auth\AuthManager;
11+
use Illuminate\Http\JsonResponse;
12+
13+
class StopImpersonationAction extends BaseAuthAction
14+
{
15+
public function __construct(
16+
private readonly StopImpersonationHandler $handler,
17+
private readonly AuthManager $authManager,
18+
)
19+
{
20+
}
21+
22+
public function __invoke(): JsonResponse
23+
{
24+
$isImpersonating = $this->authManager->payload()->get('is_impersonating');
25+
26+
if (!$isImpersonating) {
27+
return $this->errorResponse(__('Not currently impersonating'));
28+
}
29+
30+
$impersonatorId = $this->authManager->payload()->get('impersonator_id');
31+
32+
$token = $this->handler->handle(new StopImpersonationDTO(
33+
impersonatorId: $impersonatorId,
34+
));
35+
36+
$response = $this->jsonResponse([
37+
'message' => __('Impersonation ended'),
38+
'redirect_url' => '/admin/users',
39+
'token' => $token
40+
]);
41+
42+
return $this->addTokenToResponse($response, $token);
43+
}
44+
}

backend/app/Http/Kernel.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
use HiEvents\Http\Middleware\Authenticate;
66
use HiEvents\Http\Middleware\EncryptCookies;
77
use HiEvents\Http\Middleware\HandleDeprecatedTimezones;
8+
use HiEvents\Http\Middleware\LogImpersonationMiddleware;
89
use HiEvents\Http\Middleware\PreventRequestsDuringMaintenance;
910
use HiEvents\Http\Middleware\RedirectIfAuthenticated;
1011
use HiEvents\Http\Middleware\SetAccountContext;
@@ -71,6 +72,7 @@ class Kernel extends HttpKernel
7172
SubstituteBindings::class,
7273
SetAccountContext::class,
7374
SetUserLocaleMiddleware::class,
75+
LogImpersonationMiddleware::class,
7476
],
7577
];
7678

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
namespace HiEvents\Http\Middleware;
4+
5+
use Closure;
6+
use Exception;
7+
use Illuminate\Auth\AuthManager;
8+
use Illuminate\Http\Request;
9+
use Psr\Log\LoggerInterface;
10+
11+
class LogImpersonationMiddleware
12+
{
13+
public function __construct(
14+
private readonly LoggerInterface $logger,
15+
private readonly AuthManager $authManager,
16+
)
17+
{
18+
}
19+
20+
public function handle(Request $request, Closure $next)
21+
{
22+
$mutateMethods = ['POST', 'PUT', 'PATCH', 'DELETE'];
23+
24+
$isImpersonating = false;
25+
try {
26+
$isImpersonating = (bool)$this->authManager->payload()->get('is_impersonating', false);
27+
} catch (Exception) {
28+
// Not authenticated or no JWT token
29+
}
30+
31+
if ($this->authManager->check()
32+
&& $isImpersonating
33+
&& in_array($request->method(), $mutateMethods, true)
34+
) {
35+
$this->logger->info('Impersonation action by user ID ' . $this->authManager->payload()->get('impersonator_id'), [
36+
'impersonator_id' => $this->authManager->payload()->get('impersonator_id'),
37+
'impersonated_user_id' => $this->authManager->user()->id,
38+
'account_id' => $this->authManager->payload()->get('account_id'),
39+
'method' => $request->method(),
40+
'url' => $request->fullUrl(),
41+
'ip' => $request->ip(),
42+
'payload' => $request->except(['password', 'token', 'password_confirmation', 'image']),
43+
'timestamp' => now()->toIso8601String(),
44+
]);
45+
}
46+
47+
return $next($request);
48+
}
49+
}

backend/app/Http/Request/User/CreateUserRequest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public function rules(): array
1515
return [
1616
'first_name' => 'required|min:1',
1717
'last_name' => 'min:1|nullable',
18-
'role' => Rule::in(Role::valuesArray()),
18+
'role' => ['required', Rule::in(Role::getAssignableRoles())],
1919
'email' => [
2020
'required',
2121
'email',

0 commit comments

Comments
 (0)