Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,10 @@ abstract class AccountDomainObjectAbstract extends AbstractDomainObject

final public const REQUIRE_TWO_FACTOR_AUTHENTICATION = 'require_two_factor_authentication';

final public const SUSPENDED_AT = 'suspended_at';

final public const SUSPENSION_REASON = 'suspension_reason';

protected int $id;

protected ?int $account_configuration_id = null;
Expand Down Expand Up @@ -85,6 +89,10 @@ abstract class AccountDomainObjectAbstract extends AbstractDomainObject

protected bool $require_two_factor_authentication = false;

protected ?string $suspended_at = null;

protected ?string $suspension_reason = null;

public function toArray(): array
{
return [
Expand All @@ -106,6 +114,8 @@ public function toArray(): array
'is_manually_verified' => $this->is_manually_verified ?? null,
'country' => $this->country ?? null,
'require_two_factor_authentication' => $this->require_two_factor_authentication ?? null,
'suspended_at' => $this->suspended_at ?? null,
'suspension_reason' => $this->suspension_reason ?? null,
];
}

Expand Down Expand Up @@ -324,4 +334,28 @@ public function getRequireTwoFactorAuthentication(): bool
{
return $this->require_two_factor_authentication;
}

public function setSuspendedAt(?string $suspended_at): self
{
$this->suspended_at = $suspended_at;

return $this;
}

public function getSuspendedAt(): ?string
{
return $this->suspended_at;
}

public function setSuspensionReason(?string $suspension_reason): self
{
$this->suspension_reason = $suspension_reason;

return $this;
}

public function getSuspensionReason(): ?string
{
return $this->suspension_reason;
}
}
8 changes: 8 additions & 0 deletions backend/app/Exceptions/AccountSuspendedException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
<?php

namespace HiEvents\Exceptions;

class AccountSuspendedException extends CodedAccessException
{
public const ERROR_CODE = 'ACCOUNT_SUSPENDED';
}
47 changes: 47 additions & 0 deletions backend/app/Http/Actions/Admin/Accounts/SuspendAccountAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Admin\Accounts;

use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Http\Request\Admin\AdminSuspendAccountRequest;
use HiEvents\Resources\Account\AdminAccountDetailResource;
use HiEvents\Services\Application\Handlers\Admin\GetAccountHandler;
use HiEvents\Services\Domain\Account\AccountSuspensionService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response as HttpResponse;
use Throwable;

class SuspendAccountAction extends BaseAction
{
public function __construct(
private readonly AccountSuspensionService $accountSuspensionService,
private readonly GetAccountHandler $getAccountHandler,
) {}

/**
* @throws Throwable
*/
public function __invoke(AdminSuspendAccountRequest $request, int $accountId): JsonResponse
{
$this->requireSuperAdmin();

try {
$this->accountSuspensionService->suspend(
accountId: $accountId,
suspendedByUserId: $this->getAuthenticatedUser()->getId(),
reason: $request->validated('reason'),
notifyAccountOwner: $request->boolean('notify_account_owner'),
);
} catch (ResourceConflictException $exception) {
return $this->errorResponse(
message: $exception->getMessage(),
statusCode: HttpResponse::HTTP_CONFLICT,
);
}

return $this->jsonResponse(new AdminAccountDetailResource($this->getAccountHandler->handle($accountId)), wrapInData: true);
}
}
40 changes: 40 additions & 0 deletions backend/app/Http/Actions/Admin/Accounts/UnsuspendAccountAction.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

declare(strict_types=1);

namespace HiEvents\Http\Actions\Admin\Accounts;

use HiEvents\Exceptions\ResourceConflictException;
use HiEvents\Http\Actions\BaseAction;
use HiEvents\Resources\Account\AdminAccountDetailResource;
use HiEvents\Services\Application\Handlers\Admin\GetAccountHandler;
use HiEvents\Services\Domain\Account\AccountSuspensionService;
use Illuminate\Http\JsonResponse;
use Symfony\Component\HttpFoundation\Response as HttpResponse;

class UnsuspendAccountAction extends BaseAction
{
public function __construct(
private readonly AccountSuspensionService $accountSuspensionService,
private readonly GetAccountHandler $getAccountHandler,
) {}

public function __invoke(int $accountId): JsonResponse
{
$this->requireSuperAdmin();

try {
$this->accountSuspensionService->unsuspend(
accountId: $accountId,
unsuspendedByUserId: $this->getAuthenticatedUser()->getId(),
);
} catch (ResourceConflictException $exception) {
return $this->errorResponse(
message: $exception->getMessage(),
statusCode: HttpResponse::HTTP_CONFLICT,
);
}

return $this->jsonResponse(new AdminAccountDetailResource($this->getAccountHandler->handle($accountId)), wrapInData: true);
}
}
6 changes: 3 additions & 3 deletions backend/app/Http/Actions/Auth/LoginAction.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

namespace HiEvents\Http\Actions\Auth;

use HiEvents\Exceptions\InvitationPendingException;
use HiEvents\Exceptions\CodedAccessException;
use HiEvents\Exceptions\UnauthorizedException;
use HiEvents\Http\Request\Auth\LoginRequest;
use HiEvents\Http\ResponseCodes;
Expand Down Expand Up @@ -32,10 +32,10 @@ public function __invoke(LoginRequest $request): JsonResponse
accountId: (int) $request->validated('account_id'),
trustedDeviceToken: $request->cookie(TrustedDeviceService::COOKIE_NAME),
));
} catch (InvitationPendingException $e) {
} catch (CodedAccessException $e) {
return $this->jsonResponse([
'message' => $e->getMessage(),
'error_code' => InvitationPendingException::ERROR_CODE,
'error_code' => $e::ERROR_CODE,
], ResponseCodes::HTTP_UNAUTHORIZED);
} catch (UnauthorizedException $e) {
return $this->errorResponse(
Expand Down
2 changes: 2 additions & 0 deletions backend/app/Http/Kernel.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
use HiEvents\Http\Middleware\EncryptCookies;
use HiEvents\Http\Middleware\EnforceAccountUserAccess;
use HiEvents\Http\Middleware\EnsureAccountIsNotPendingDeletion;
use HiEvents\Http\Middleware\EnsureAccountIsNotSuspended;
use HiEvents\Http\Middleware\EnsureLifecycleEmailsAvailable;
use HiEvents\Http\Middleware\EnsureTokenNotRevoked;
use HiEvents\Http\Middleware\EnsureTwoFactorEnrolled;
Expand Down Expand Up @@ -84,6 +85,7 @@ class Kernel extends HttpKernel
ThrottleRequests::class.':api',
SubstituteBindings::class,
EnsureTokenNotRevoked::class,
EnsureAccountIsNotSuspended::class,
SetAccountContext::class,
EnsureAccountIsNotPendingDeletion::class,
EnforceAccountUserAccess::class,
Expand Down
54 changes: 54 additions & 0 deletions backend/app/Http/Middleware/EnsureAccountIsNotSuspended.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
<?php

namespace HiEvents\Http\Middleware;

use Closure;
use HiEvents\Exceptions\AccountSuspendedException;
use HiEvents\Services\Domain\Account\AccountSuspensionService;
use HiEvents\Services\Domain\Auth\AuthUserService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;

class EnsureAccountIsNotSuspended
{
public function __construct(
private readonly AuthUserService $authUserService,
private readonly AccountSuspensionService $accountSuspensionService,
) {}

public function handle(Request $request, Closure $next)
{
if (! Auth::check() || $this->authUserService->isImpersonating() || $this->isAuthPath($request)) {
return $next($request);
}

$accountId = $this->authUserService->getAuthenticatedAccountId();

if ($accountId === null || ! $this->accountSuspensionService->isAccountSuspended($accountId)) {
return $next($request);
}

if (! $this->routeRequiresAuthentication($request)) {
$this->authUserService->treatRequestAsAnonymous($request);

return $next($request);
}

return response()->json([
'message' => __('This account has been suspended.'),
'error_code' => AccountSuspendedException::ERROR_CODE,
], Response::HTTP_FORBIDDEN);
}

private function routeRequiresAuthentication(Request $request): bool
{
return collect($request->route()?->gatherMiddleware() ?? [])
->contains(fn ($middleware) => is_string($middleware) && str_starts_with($middleware, 'auth:'));
}

private function isAuthPath(Request $request): bool
{
return str_starts_with(trim($request->path(), '/'), 'auth/');
}
}
12 changes: 1 addition & 11 deletions backend/app/Http/Middleware/EnsureTokenNotRevoked.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ public function handle(Request $request, Closure $next)
], Response::HTTP_UNAUTHORIZED);
}

$this->treatAsAnonymous($request);
$this->authUserService->treatRequestAsAnonymous($request);

return $next($request);
}
Expand Down Expand Up @@ -94,16 +94,6 @@ private function carriesToken(Request $request): bool
return $request->bearerToken() !== null || $request->cookies->has('token');
}

private function treatAsAnonymous(Request $request): void
{
$request->headers->remove('Authorization');
$request->server->remove('HTTP_AUTHORIZATION');
$request->server->remove('REDIRECT_HTTP_AUTHORIZATION');
$request->cookies->remove('token');
app('tymon.jwt')->unsetToken();
Auth::guard('api')->forgetUser();
}

private function routeRequiresAuthentication(Request $request): bool
{
return collect($request->route()?->gatherMiddleware() ?? [])
Expand Down
16 changes: 16 additions & 0 deletions backend/app/Http/Request/Admin/AdminSuspendAccountRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?php

namespace HiEvents\Http\Request\Admin;

use HiEvents\Http\Request\BaseRequest;

class AdminSuspendAccountRequest extends BaseRequest
{
public function rules(): array
{
return [
'reason' => ['nullable', 'string', 'max:1000'],
'notify_account_owner' => ['sometimes', 'boolean'],
];
}
}
40 changes: 40 additions & 0 deletions backend/app/Mail/Account/AccountSuspendedEmail.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<?php

namespace HiEvents\Mail\Account;

use HiEvents\DomainObjects\AccountDomainObject;
use HiEvents\Mail\BaseMail;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;

/**
* @uses /backend/resources/views/emails/account/suspended.blade.php
*/
class AccountSuspendedEmail extends BaseMail
{
public function __construct(
private readonly AccountDomainObject $account,
) {
parent::__construct();
}

public function envelope(): Envelope
{
return new Envelope(
subject: __('Your :app_name account has been suspended', [
'app_name' => config('app.name'),
]),
);
}

public function content(): Content
{
return new Content(
markdown: 'emails.account.suspended',
with: [
'reason' => $this->account->getSuspensionReason(),
'supportEmail' => config('app.platform_support_email'),
],
);
}
}
2 changes: 2 additions & 0 deletions backend/app/Resources/Account/AdminAccountDetailResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ public function toArray(Request $request): array
'timezone' => $this->resource->timezone,
'currency_code' => $this->resource->currency_code,
'is_manually_verified' => (bool) $this->resource->is_manually_verified,
'suspended_at' => $this->resource->suspended_at,
'suspension_reason' => $this->resource->suspension_reason,
'created_at' => $this->resource->created_at,
'updated_at' => $this->resource->updated_at,
'events_count' => $this->resource->events_count ?? 0,
Expand Down
1 change: 1 addition & 0 deletions backend/app/Resources/Account/AdminAccountResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ public function toArray(Request $request): array
'timezone' => $this->resource->timezone,
'currency_code' => $this->resource->currency_code,
'created_at' => $this->resource->created_at,
'suspended_at' => $this->resource->suspended_at,
'events_count' => $this->resource->events_count ?? 0,
'users_count' => $this->resource->users_count ?? 0,
'users' => $this->resource->users->map(function ($user) {
Expand Down
Loading
Loading