diff --git a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php index a4913fa2fe..a68147d707 100644 --- a/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php +++ b/backend/app/DomainObjects/Generated/AccountDomainObjectAbstract.php @@ -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; @@ -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 [ @@ -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, ]; } @@ -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; + } } diff --git a/backend/app/Exceptions/AccountSuspendedException.php b/backend/app/Exceptions/AccountSuspendedException.php new file mode 100644 index 0000000000..95b2ff5794 --- /dev/null +++ b/backend/app/Exceptions/AccountSuspendedException.php @@ -0,0 +1,8 @@ +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); + } +} diff --git a/backend/app/Http/Actions/Admin/Accounts/UnsuspendAccountAction.php b/backend/app/Http/Actions/Admin/Accounts/UnsuspendAccountAction.php new file mode 100644 index 0000000000..ee5a99e77c --- /dev/null +++ b/backend/app/Http/Actions/Admin/Accounts/UnsuspendAccountAction.php @@ -0,0 +1,40 @@ +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); + } +} diff --git a/backend/app/Http/Actions/Auth/LoginAction.php b/backend/app/Http/Actions/Auth/LoginAction.php index e166e41aa5..6c4159f348 100644 --- a/backend/app/Http/Actions/Auth/LoginAction.php +++ b/backend/app/Http/Actions/Auth/LoginAction.php @@ -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; @@ -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( diff --git a/backend/app/Http/Kernel.php b/backend/app/Http/Kernel.php index f1ab69fa2f..14070b3510 100644 --- a/backend/app/Http/Kernel.php +++ b/backend/app/Http/Kernel.php @@ -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; @@ -84,6 +85,7 @@ class Kernel extends HttpKernel ThrottleRequests::class.':api', SubstituteBindings::class, EnsureTokenNotRevoked::class, + EnsureAccountIsNotSuspended::class, SetAccountContext::class, EnsureAccountIsNotPendingDeletion::class, EnforceAccountUserAccess::class, diff --git a/backend/app/Http/Middleware/EnsureAccountIsNotSuspended.php b/backend/app/Http/Middleware/EnsureAccountIsNotSuspended.php new file mode 100644 index 0000000000..4d593ea292 --- /dev/null +++ b/backend/app/Http/Middleware/EnsureAccountIsNotSuspended.php @@ -0,0 +1,54 @@ +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/'); + } +} diff --git a/backend/app/Http/Middleware/EnsureTokenNotRevoked.php b/backend/app/Http/Middleware/EnsureTokenNotRevoked.php index f79aac0094..1cd7d7682b 100644 --- a/backend/app/Http/Middleware/EnsureTokenNotRevoked.php +++ b/backend/app/Http/Middleware/EnsureTokenNotRevoked.php @@ -43,7 +43,7 @@ public function handle(Request $request, Closure $next) ], Response::HTTP_UNAUTHORIZED); } - $this->treatAsAnonymous($request); + $this->authUserService->treatRequestAsAnonymous($request); return $next($request); } @@ -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() ?? []) diff --git a/backend/app/Http/Request/Admin/AdminSuspendAccountRequest.php b/backend/app/Http/Request/Admin/AdminSuspendAccountRequest.php new file mode 100644 index 0000000000..d226acef2c --- /dev/null +++ b/backend/app/Http/Request/Admin/AdminSuspendAccountRequest.php @@ -0,0 +1,16 @@ + ['nullable', 'string', 'max:1000'], + 'notify_account_owner' => ['sometimes', 'boolean'], + ]; + } +} diff --git a/backend/app/Mail/Account/AccountSuspendedEmail.php b/backend/app/Mail/Account/AccountSuspendedEmail.php new file mode 100644 index 0000000000..15480f0f02 --- /dev/null +++ b/backend/app/Mail/Account/AccountSuspendedEmail.php @@ -0,0 +1,40 @@ + 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'), + ], + ); + } +} diff --git a/backend/app/Resources/Account/AdminAccountDetailResource.php b/backend/app/Resources/Account/AdminAccountDetailResource.php index 9342dc7ba1..b6d17fcdaf 100644 --- a/backend/app/Resources/Account/AdminAccountDetailResource.php +++ b/backend/app/Resources/Account/AdminAccountDetailResource.php @@ -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, diff --git a/backend/app/Resources/Account/AdminAccountResource.php b/backend/app/Resources/Account/AdminAccountResource.php index 8b3117ed2c..0935e80885 100644 --- a/backend/app/Resources/Account/AdminAccountResource.php +++ b/backend/app/Resources/Account/AdminAccountResource.php @@ -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) { diff --git a/backend/app/Services/Domain/Account/AccountSuspensionService.php b/backend/app/Services/Domain/Account/AccountSuspensionService.php new file mode 100644 index 0000000000..53e9278b8f --- /dev/null +++ b/backend/app/Services/Domain/Account/AccountSuspensionService.php @@ -0,0 +1,149 @@ +getSuspendedCacheKey($accountId), + ttl: self::SUSPENDED_CACHE_TTL_SECONDS, + callback: fn () => $this->accountRepository->findFirstWhere([ + AccountDomainObjectAbstract::ID => $accountId, + ])?->getSuspendedAt() !== null, + ); + } + + /** + * @throws ResourceConflictException + * @throws Throwable + */ + public function suspend( + int $accountId, + int $suspendedByUserId, + ?string $reason, + bool $notifyAccountOwner, + ): AccountDomainObject { + $account = $this->databaseManager->transaction(function () use ($accountId, $reason, $notifyAccountOwner) { + $account = $this->accountRepository->findById($accountId); + + if ($account->getSuspendedAt() !== null) { + throw new ResourceConflictException(__('This account is already suspended.')); + } + + if ($this->accountHasSuperadminMember($accountId)) { + throw new ResourceConflictException(__('Accounts with a superadmin member cannot be suspended.')); + } + + $account = $this->accountRepository->updateFromArray($accountId, [ + AccountDomainObjectAbstract::SUSPENDED_AT => now()->toDateTimeString(), + AccountDomainObjectAbstract::SUSPENSION_REASON => $reason, + ]); + + $this->eventRepository->updateWhere( + attributes: [EventDomainObjectAbstract::STATUS => EventStatus::DRAFT->name], + where: [ + EventDomainObjectAbstract::ACCOUNT_ID => $accountId, + [EventDomainObjectAbstract::STATUS, 'in', [ + EventStatus::LIVE->name, + EventStatus::PENDING_MANUAL_REVIEW->name, + ]], + ], + ); + + $this->organizerRepository->updateWhere( + attributes: [OrganizerDomainObjectAbstract::STATUS => OrganizerStatus::DRAFT->name], + where: [ + OrganizerDomainObjectAbstract::ACCOUNT_ID => $accountId, + OrganizerDomainObjectAbstract::STATUS => OrganizerStatus::LIVE->name, + ], + ); + + if ($notifyAccountOwner) { + Mail::to($account->getEmail())->queue(new AccountSuspendedEmail($account)); + } + + return $account; + }); + + Cache::forget($this->getSuspendedCacheKey($accountId)); + + $this->logger->info('Account suspended', [ + 'account_id' => $accountId, + 'suspended_by_user_id' => $suspendedByUserId, + 'account_owner_notified' => $notifyAccountOwner, + ]); + + return $account; + } + + /** + * @throws ResourceConflictException + */ + public function unsuspend(int $accountId, int $unsuspendedByUserId): AccountDomainObject + { + $account = $this->accountRepository->findById($accountId); + + if ($account->getSuspendedAt() === null) { + throw new ResourceConflictException(__('This account is not suspended.')); + } + + $account = $this->accountRepository->updateFromArray($accountId, [ + AccountDomainObjectAbstract::SUSPENDED_AT => null, + AccountDomainObjectAbstract::SUSPENSION_REASON => null, + ]); + + Cache::forget($this->getSuspendedCacheKey($accountId)); + + $this->logger->info('Account unsuspended', [ + 'account_id' => $accountId, + 'unsuspended_by_user_id' => $unsuspendedByUserId, + ]); + + return $account; + } + + private function accountHasSuperadminMember(int $accountId): bool + { + return $this->accountUserRepository->countWhere([ + 'account_id' => $accountId, + 'role' => Role::SUPERADMIN->name, + ]) > 0; + } + + private function getSuspendedCacheKey(int $accountId): string + { + return "account:{$accountId}:suspended"; + } +} diff --git a/backend/app/Services/Domain/Auth/AuthUserService.php b/backend/app/Services/Domain/Auth/AuthUserService.php index 50905672aa..4b6ebd7fc5 100644 --- a/backend/app/Services/Domain/Auth/AuthUserService.php +++ b/backend/app/Services/Domain/Auth/AuthUserService.php @@ -8,6 +8,7 @@ use HiEvents\Models\User; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; use Illuminate\Auth\AuthManager; +use Illuminate\Http\Request; use Laravel\Sanctum\PersonalAccessToken; use PHPOpenSourceSaver\JWTAuth\Exceptions\JWTException; use Throwable; @@ -100,6 +101,16 @@ public function logout(): void } } + public function treatRequestAsAnonymous(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(); + $this->authManager->guard('api')->forgetUser(); + } + public function getUser(): UserDomainObject|DomainObjectInterface|null { /** @var User $user */ diff --git a/backend/app/Services/Domain/Auth/LoginService.php b/backend/app/Services/Domain/Auth/LoginService.php index 9ef6263003..8203593281 100644 --- a/backend/app/Services/Domain/Auth/LoginService.php +++ b/backend/app/Services/Domain/Auth/LoginService.php @@ -10,6 +10,7 @@ use HiEvents\Enterprise\Sso\Exceptions\SsoRequiredException; use HiEvents\Enterprise\Sso\Repository\Interfaces\AccountSsoConnectionRepositoryInterface; use HiEvents\Enterprise\Sso\Services\Domain\SsoEnforcementService; +use HiEvents\Exceptions\AccountSuspendedException; use HiEvents\Exceptions\InvitationPendingException; use HiEvents\Exceptions\UnauthorizedException; use HiEvents\Models\User; @@ -175,14 +176,27 @@ private function resolveAccountToSignInTo(UserDomainObject $user, ?int $requeste ); } + /** + * @throws AccountSuspendedException + */ private function findActiveUserAccounts(UserDomainObject $user): Collection { - return $this->accountUserRepository + $activeMemberships = $this->accountUserRepository ->loadRelation(new Relationship(domainObject: AccountDomainObject::class, name: 'account')) ->findWhere([ 'user_id' => $user->getId(), 'status' => UserStatus::ACTIVE->name, ]); + + $membershipsOfUnsuspendedAccounts = $activeMemberships + ->filter(fn (AccountUserDomainObject $accountUser) => $accountUser->getAccount()?->getSuspendedAt() === null) + ->values(); + + if ($membershipsOfUnsuspendedAccounts->isEmpty() && $activeMemberships->isNotEmpty()) { + throw new AccountSuspendedException(__('This account has been suspended.')); + } + + return $membershipsOfUnsuspendedAccounts; } /** diff --git a/backend/database/migrations/2026_10_23_100000_add_suspension_columns_to_accounts.php b/backend/database/migrations/2026_10_23_100000_add_suspension_columns_to_accounts.php new file mode 100644 index 0000000000..9b8b8a916b --- /dev/null +++ b/backend/database/migrations/2026_10_23_100000_add_suspension_columns_to_accounts.php @@ -0,0 +1,23 @@ +timestamp('suspended_at')->nullable(); + $table->text('suspension_reason')->nullable(); + }); + } + + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + $table->dropColumn(['suspended_at', 'suspension_reason']); + }); + } +}; diff --git a/backend/lang/de.json b/backend/lang/de.json index a223038eec..66d8705b63 100644 --- a/backend/lang/de.json +++ b/backend/lang/de.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Nur eine wiederkehrende Veranstaltung mit genau einem Datum kann in eine Einzelveranstaltung umgewandelt werden. Löschen Sie zuerst die anderen Daten.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Das verbleibende Datum ist abgesagt. Reaktivieren Sie es, bevor Sie zu einer Einzelveranstaltung wechseln.", "A recurring event with orders cannot be changed to a single event.": "Eine wiederkehrende Veranstaltung mit Bestellungen kann nicht in eine Einzelveranstaltung umgewandelt werden.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Sie haben für diese Veranstaltung bereits Tickets reserviert. Schließen Sie diese Bestellung ab oder stornieren Sie sie, bevor Sie weitere reservieren." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Sie haben für diese Veranstaltung bereits Tickets reserviert. Schließen Sie diese Bestellung ab oder stornieren Sie sie, bevor Sie weitere reservieren.", + "This account is already suspended.": "Dieses Konto ist bereits gesperrt.", + "Accounts with a superadmin member cannot be suspended.": "Konten mit einem Superadmin-Mitglied können nicht gesperrt werden.", + "This account is not suspended.": "Dieses Konto ist nicht gesperrt.", + "This account has been suspended.": "Dieses Konto wurde gesperrt.", + "Your :app_name account has been suspended": "Ihr :app_name-Konto wurde gesperrt", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ihr :appName-Konto wurde gesperrt. Ihre Veranstaltungen und Veranstalterseiten wurden zurückgezogen, und Sie können sich nicht mehr bei dem Konto anmelden.", + "Reason:": "Grund:", + "If you think this is a mistake, contact us at :email.": "Wenn Sie glauben, dass es sich um einen Fehler handelt, kontaktieren Sie uns unter :email." } diff --git a/backend/lang/el.json b/backend/lang/el.json index c28584b2cd..0f3866c53f 100644 --- a/backend/lang/el.json +++ b/backend/lang/el.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Μόνο μια επαναλαμβανόμενη εκδήλωση με ακριβώς μία ημερομηνία μπορεί να αλλάξει σε μεμονωμένη εκδήλωση. Διαγράψτε πρώτα τις άλλες ημερομηνίες.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Η εναπομείνασα ημερομηνία έχει ακυρωθεί. Ενεργοποιήστε την ξανά πριν την αλλαγή σε μεμονωμένη εκδήλωση.", "A recurring event with orders cannot be changed to a single event.": "Μια επαναλαμβανόμενη εκδήλωση με παραγγελίες δεν μπορεί να αλλάξει σε μεμονωμένη εκδήλωση.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Έχετε ήδη κρατήσει εισιτήρια για αυτήν την εκδήλωση. Ολοκληρώστε ή ακυρώστε αυτήν την παραγγελία πριν κάνετε νέα κράτηση." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Έχετε ήδη κρατήσει εισιτήρια για αυτήν την εκδήλωση. Ολοκληρώστε ή ακυρώστε αυτήν την παραγγελία πριν κάνετε νέα κράτηση.", + "This account is already suspended.": "Αυτός ο λογαριασμός έχει ήδη ανασταλεί.", + "Accounts with a superadmin member cannot be suspended.": "Οι λογαριασμοί με μέλος υπερδιαχειριστή δεν μπορούν να ανασταλούν.", + "This account is not suspended.": "Αυτός ο λογαριασμός δεν είναι σε αναστολή.", + "This account has been suspended.": "Αυτός ο λογαριασμός έχει ανασταλεί.", + "Your :app_name account has been suspended": "Ο λογαριασμός σας στο :app_name έχει ανασταλεί", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ο λογαριασμός σας στο :appName έχει ανασταλεί. Οι εκδηλώσεις και οι σελίδες διοργανωτή σας αποσύρθηκαν και δεν μπορείτε πλέον να συνδεθείτε στον λογαριασμό.", + "Reason:": "Αιτία:", + "If you think this is a mistake, contact us at :email.": "Αν πιστεύετε ότι πρόκειται για λάθος, επικοινωνήστε μαζί μας στο :email." } diff --git a/backend/lang/es.json b/backend/lang/es.json index fa734c1570..7e07a34d18 100644 --- a/backend/lang/es.json +++ b/backend/lang/es.json @@ -1244,5 +1244,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Solo un evento recurrente con exactamente una fecha puede cambiarse a un evento único. Elimina primero las demás fechas.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "La fecha restante está cancelada. Reactívala antes de cambiar a un evento único.", "A recurring event with orders cannot be changed to a single event.": "Un evento recurrente con pedidos no se puede cambiar a un evento único.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Ya tienes entradas reservadas para este evento. Completa o cancela ese pedido antes de reservar más." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Ya tienes entradas reservadas para este evento. Completa o cancela ese pedido antes de reservar más.", + "This account is already suspended.": "Esta cuenta ya está suspendida.", + "Accounts with a superadmin member cannot be suspended.": "Las cuentas con un miembro superadministrador no se pueden suspender.", + "This account is not suspended.": "Esta cuenta no está suspendida.", + "This account has been suspended.": "Esta cuenta ha sido suspendida.", + "Your :app_name account has been suspended": "Tu cuenta de :app_name ha sido suspendida", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Tu cuenta de :appName ha sido suspendida. Tus eventos y páginas de organizador se han despublicado y ya no puedes iniciar sesión en la cuenta.", + "Reason:": "Motivo:", + "If you think this is a mistake, contact us at :email.": "Si crees que se trata de un error, escríbenos a :email." } diff --git a/backend/lang/fi.json b/backend/lang/fi.json index f2977fc67a..f30736c015 100644 --- a/backend/lang/fi.json +++ b/backend/lang/fi.json @@ -1392,5 +1392,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Vain toistuvan tapahtuman, jolla on täsmälleen yksi päivämäärä, voi muuttaa kertaluonteiseksi. Poista ensin muut päivämäärät.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Jäljellä oleva päivämäärä on peruttu. Aktivoi se uudelleen ennen kuin muutat tapahtuman kertaluonteiseksi.", "A recurring event with orders cannot be changed to a single event.": "Toistuvaa tapahtumaa, jolla on tilauksia, ei voi muuttaa kertaluonteiseksi.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Sinulla on jo varattuja lippuja tähän tapahtumaan. Viimeistele tai peru tilaus ennen kuin varaat lisää." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Sinulla on jo varattuja lippuja tähän tapahtumaan. Viimeistele tai peru tilaus ennen kuin varaat lisää.", + "This account is already suspended.": "Tämä tili on jo jäädytetty.", + "Accounts with a superadmin member cannot be suspended.": "Tilejä, joilla on pääylläpitäjäjäsen, ei voi jäädyttää.", + "This account is not suspended.": "Tätä tiliä ei ole jäädytetty.", + "This account has been suspended.": "Tämä tili on jäädytetty.", + "Your :app_name account has been suspended": ":app_name-tilisi on jäädytetty", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName-tilisi on jäädytetty. Tapahtumasi ja järjestäjäsivusi on poistettu julkaisusta, etkä voi enää kirjautua tilille.", + "Reason:": "Syy:", + "If you think this is a mistake, contact us at :email.": "Jos uskot tämän olevan virhe, ota meihin yhteyttä osoitteessa :email." } diff --git a/backend/lang/fr.json b/backend/lang/fr.json index 8fffea414c..6af58c5c85 100644 --- a/backend/lang/fr.json +++ b/backend/lang/fr.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Seul un événement récurrent ayant exactement une date peut être transformé en événement unique. Supprimez d'abord les autres dates.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "La date restante est annulée. Réactivez-la avant de passer à un événement unique.", "A recurring event with orders cannot be changed to a single event.": "Un événement récurrent comportant des commandes ne peut pas être transformé en événement unique.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Vous avez déjà des billets réservés pour cet événement. Finalisez ou annulez cette commande avant d'en réserver davantage." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Vous avez déjà des billets réservés pour cet événement. Finalisez ou annulez cette commande avant d'en réserver davantage.", + "This account is already suspended.": "Ce compte est déjà suspendu.", + "Accounts with a superadmin member cannot be suspended.": "Les comptes ayant un membre superadministrateur ne peuvent pas être suspendus.", + "This account is not suspended.": "Ce compte n'est pas suspendu.", + "This account has been suspended.": "Ce compte a été suspendu.", + "Your :app_name account has been suspended": "Votre compte :app_name a été suspendu", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Votre compte :appName a été suspendu. Vos événements et pages d'organisateur ont été dépubliés et vous ne pouvez plus vous connecter au compte.", + "Reason:": "Motif :", + "If you think this is a mistake, contact us at :email.": "Si vous pensez qu'il s'agit d'une erreur, contactez-nous à :email." } diff --git a/backend/lang/hu.json b/backend/lang/hu.json index 651667aa17..7ce24b435d 100644 --- a/backend/lang/hu.json +++ b/backend/lang/hu.json @@ -1209,5 +1209,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Csak pontosan egy dátummal rendelkező ismétlődő esemény alakítható egyszeri eseménnyé. Először törölje a többi dátumot.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "A megmaradt dátum le van mondva. Aktiválja újra, mielőtt egyszeri eseményre váltana.", "A recurring event with orders cannot be changed to a single event.": "Rendelésekkel rendelkező ismétlődő esemény nem alakítható egyszeri eseménnyé.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Már foglalt jegyeket erre az eseményre. Fejezze be vagy mondja le azt a rendelést, mielőtt újabbat foglalna." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Már foglalt jegyeket erre az eseményre. Fejezze be vagy mondja le azt a rendelést, mielőtt újabbat foglalna.", + "This account is already suspended.": "Ez a fiók már fel van függesztve.", + "Accounts with a superadmin member cannot be suspended.": "A szuperadminisztrátor taggal rendelkező fiókok nem függeszthetők fel.", + "This account is not suspended.": "Ez a fiók nincs felfüggesztve.", + "This account has been suspended.": "Ezt a fiókot felfüggesztették.", + "Your :app_name account has been suspended": ":app_name fiókodat felfüggesztették", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName fiókodat felfüggesztették. Eseményeidet és szervezői oldalaidat visszavontuk, és többé nem tudsz bejelentkezni a fiókba.", + "Reason:": "Indok:", + "If you think this is a mistake, contact us at :email.": "Ha úgy gondolod, hogy ez tévedés, írj nekünk: :email." } diff --git a/backend/lang/it.json b/backend/lang/it.json index 4b077ee269..aa007e69ad 100644 --- a/backend/lang/it.json +++ b/backend/lang/it.json @@ -1205,5 +1205,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Solo un evento ricorrente con esattamente una data può essere trasformato in evento singolo. Elimina prima le altre date.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "La data rimanente è annullata. Riattivala prima di passare a un evento singolo.", "A recurring event with orders cannot be changed to a single event.": "Un evento ricorrente con ordini non può essere trasformato in evento singolo.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Hai già dei biglietti prenotati per questo evento. Completa o annulla quell'ordine prima di prenotarne altri." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Hai già dei biglietti prenotati per questo evento. Completa o annulla quell'ordine prima di prenotarne altri.", + "This account is already suspended.": "Questo account è già sospeso.", + "Accounts with a superadmin member cannot be suspended.": "Gli account con un membro superadmin non possono essere sospesi.", + "This account is not suspended.": "Questo account non è sospeso.", + "This account has been suspended.": "Questo account è stato sospeso.", + "Your :app_name account has been suspended": "Il tuo account :app_name è stato sospeso", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Il tuo account :appName è stato sospeso. I tuoi eventi e le tue pagine organizzatore sono stati ritirati e non puoi più accedere all'account.", + "Reason:": "Motivo:", + "If you think this is a mistake, contact us at :email.": "Se pensi che si tratti di un errore, contattaci a :email." } diff --git a/backend/lang/ja.json b/backend/lang/ja.json index d51b514b3d..77e87b5294 100644 --- a/backend/lang/ja.json +++ b/backend/lang/ja.json @@ -1392,5 +1392,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "日程が1つだけの定期イベントのみ単発イベントに変更できます。先に他の日程を削除してください。", "The remaining date is cancelled. Reactivate it before changing to a single event.": "残っている日程はキャンセルされています。単発イベントに変更する前に再開してください。", "A recurring event with orders cannot be changed to a single event.": "注文がある定期イベントは単発イベントに変更できません。", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "このイベントのチケットはすでに予約されています。追加で予約する前に、その注文を完了するかキャンセルしてください。" + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "このイベントのチケットはすでに予約されています。追加で予約する前に、その注文を完了するかキャンセルしてください。", + "This account is already suspended.": "このアカウントはすでに停止されています。", + "Accounts with a superadmin member cannot be suspended.": "スーパー管理者のメンバーがいるアカウントは停止できません。", + "This account is not suspended.": "このアカウントは停止されていません。", + "This account has been suspended.": "このアカウントは停止されています。", + "Your :app_name account has been suspended": ":app_name のアカウントが停止されました", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName のアカウントが停止されました。イベントと主催者ページは非公開になり、このアカウントにはサインインできなくなりました。", + "Reason:": "理由:", + "If you think this is a mistake, contact us at :email.": "誤りだと思われる場合は :email までご連絡ください。" } diff --git a/backend/lang/ko.json b/backend/lang/ko.json index b517a6ddd9..fe7fcf6049 100644 --- a/backend/lang/ko.json +++ b/backend/lang/ko.json @@ -1393,5 +1393,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "날짜가 정확히 하나인 반복 이벤트만 단일 이벤트로 변경할 수 있습니다. 먼저 다른 날짜를 삭제하세요.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "남은 날짜가 취소되었습니다. 단일 이벤트로 변경하기 전에 다시 활성화하세요.", "A recurring event with orders cannot be changed to a single event.": "주문이 있는 반복 이벤트는 단일 이벤트로 변경할 수 없습니다.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "이 이벤트의 티켓이 이미 예약되어 있습니다. 더 예약하기 전에 해당 주문을 완료하거나 취소하세요." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "이 이벤트의 티켓이 이미 예약되어 있습니다. 더 예약하기 전에 해당 주문을 완료하거나 취소하세요.", + "This account is already suspended.": "이 계정은 이미 정지되었습니다.", + "Accounts with a superadmin member cannot be suspended.": "슈퍼 관리자 멤버가 있는 계정은 정지할 수 없습니다.", + "This account is not suspended.": "이 계정은 정지되지 않았습니다.", + "This account has been suspended.": "이 계정은 정지되었습니다.", + "Your :app_name account has been suspended": ":app_name 계정이 정지되었습니다", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName 계정이 정지되었습니다. 이벤트와 주최자 페이지가 게시 중단되었으며 더 이상 계정에 로그인할 수 없습니다.", + "Reason:": "사유:", + "If you think this is a mistake, contact us at :email.": "오류라고 생각되면 :email로 문의해 주세요." } diff --git a/backend/lang/nl.json b/backend/lang/nl.json index 9f8d140377..2c81e3f402 100644 --- a/backend/lang/nl.json +++ b/backend/lang/nl.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Alleen een terugkerend evenement met precies één datum kan worden omgezet naar een eenmalig evenement. Verwijder eerst de andere datums.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "De resterende datum is geannuleerd. Activeer deze opnieuw voordat je overschakelt naar een eenmalig evenement.", "A recurring event with orders cannot be changed to a single event.": "Een terugkerend evenement met bestellingen kan niet worden omgezet naar een eenmalig evenement.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Je hebt al tickets gereserveerd voor dit evenement. Rond die bestelling af of annuleer deze voordat je meer reserveert." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Je hebt al tickets gereserveerd voor dit evenement. Rond die bestelling af of annuleer deze voordat je meer reserveert.", + "This account is already suspended.": "Dit account is al opgeschort.", + "Accounts with a superadmin member cannot be suspended.": "Accounts met een superbeheerder als lid kunnen niet worden opgeschort.", + "This account is not suspended.": "Dit account is niet opgeschort.", + "This account has been suspended.": "Dit account is opgeschort.", + "Your :app_name account has been suspended": "Je :app_name-account is opgeschort", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Je :appName-account is opgeschort. Je evenementen en organisatorpagina's zijn offline gehaald en je kunt niet meer inloggen op het account.", + "Reason:": "Reden:", + "If you think this is a mistake, contact us at :email.": "Denk je dat dit een vergissing is? Neem contact met ons op via :email." } diff --git a/backend/lang/pl.json b/backend/lang/pl.json index 739dd079bc..d3f3504f59 100644 --- a/backend/lang/pl.json +++ b/backend/lang/pl.json @@ -1392,5 +1392,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Tylko wydarzenie cykliczne z dokładnie jedną datą można zmienić na wydarzenie jednorazowe. Najpierw usuń pozostałe daty.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Pozostała data została odwołana. Aktywuj ją ponownie przed zmianą na wydarzenie jednorazowe.", "A recurring event with orders cannot be changed to a single event.": "Wydarzenia cyklicznego z zamówieniami nie można zmienić na wydarzenie jednorazowe.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Masz już zarezerwowane bilety na to wydarzenie. Dokończ lub anuluj to zamówienie, zanim zarezerwujesz kolejne." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Masz już zarezerwowane bilety na to wydarzenie. Dokończ lub anuluj to zamówienie, zanim zarezerwujesz kolejne.", + "This account is already suspended.": "To konto jest już zawieszone.", + "Accounts with a superadmin member cannot be suspended.": "Kont z członkiem będącym superadministratorem nie można zawiesić.", + "This account is not suspended.": "To konto nie jest zawieszone.", + "This account has been suspended.": "To konto zostało zawieszone.", + "Your :app_name account has been suspended": "Twoje konto :app_name zostało zawieszone", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Twoje konto :appName zostało zawieszone. Twoje wydarzenia i strony organizatora zostały wycofane z publikacji i nie możesz już logować się na to konto.", + "Reason:": "Powód:", + "If you think this is a mistake, contact us at :email.": "Jeśli uważasz, że to pomyłka, napisz do nas na :email." } diff --git a/backend/lang/pt-br.json b/backend/lang/pt-br.json index 550d683a62..8b5b15d2d8 100644 --- a/backend/lang/pt-br.json +++ b/backend/lang/pt-br.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Somente um evento recorrente com exatamente uma data pode ser alterado para um evento único. Exclua as outras datas primeiro.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "A data restante está cancelada. Reative-a antes de mudar para um evento único.", "A recurring event with orders cannot be changed to a single event.": "Um evento recorrente com pedidos não pode ser alterado para um evento único.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Você já tem ingressos reservados para este evento. Conclua ou cancele esse pedido antes de reservar mais." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Você já tem ingressos reservados para este evento. Conclua ou cancele esse pedido antes de reservar mais.", + "This account is already suspended.": "Esta conta já está suspensa.", + "Accounts with a superadmin member cannot be suspended.": "Contas com um membro superadministrador não podem ser suspensas.", + "This account is not suspended.": "Esta conta não está suspensa.", + "This account has been suspended.": "Esta conta foi suspensa.", + "Your :app_name account has been suspended": "Sua conta :app_name foi suspensa", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Sua conta :appName foi suspensa. Seus eventos e páginas de organizador foram despublicados e você não pode mais entrar na conta.", + "Reason:": "Motivo:", + "If you think this is a mistake, contact us at :email.": "Se achar que é um engano, fale com a gente em :email." } diff --git a/backend/lang/pt.json b/backend/lang/pt.json index e004f5a0f4..2061ac69bb 100644 --- a/backend/lang/pt.json +++ b/backend/lang/pt.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Apenas um evento recorrente com exatamente uma data pode ser alterado para um evento único. Elimine primeiro as outras datas.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "A data restante está cancelada. Reative-a antes de mudar para um evento único.", "A recurring event with orders cannot be changed to a single event.": "Um evento recorrente com encomendas não pode ser alterado para um evento único.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Já tem bilhetes reservados para este evento. Conclua ou cancele essa encomenda antes de reservar mais." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Já tem bilhetes reservados para este evento. Conclua ou cancele essa encomenda antes de reservar mais.", + "This account is already suspended.": "Esta conta já está suspensa.", + "Accounts with a superadmin member cannot be suspended.": "Contas com um membro superadministrador não podem ser suspensas.", + "This account is not suspended.": "Esta conta não está suspensa.", + "This account has been suspended.": "Esta conta foi suspensa.", + "Your :app_name account has been suspended": "A sua conta :app_name foi suspensa", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "A sua conta :appName foi suspensa. Os seus eventos e páginas de organizador foram despublicados e já não pode iniciar sessão na conta.", + "Reason:": "Motivo:", + "If you think this is a mistake, contact us at :email.": "Se achar que se trata de um erro, contacte-nos através de :email." } diff --git a/backend/lang/ru.json b/backend/lang/ru.json index 40f24b4283..275b37722e 100644 --- a/backend/lang/ru.json +++ b/backend/lang/ru.json @@ -1146,5 +1146,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Только повторяющееся мероприятие ровно с одной датой можно сделать разовым. Сначала удалите остальные даты.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Оставшаяся дата отменена. Восстановите её, прежде чем менять мероприятие на разовое.", "A recurring event with orders cannot be changed to a single event.": "Повторяющееся мероприятие с заказами нельзя сделать разовым.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "У вас уже есть забронированные билеты на это мероприятие. Завершите или отмените этот заказ, прежде чем бронировать новые." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "У вас уже есть забронированные билеты на это мероприятие. Завершите или отмените этот заказ, прежде чем бронировать новые.", + "This account is already suspended.": "Этот аккаунт уже заблокирован.", + "Accounts with a superadmin member cannot be suspended.": "Аккаунты с участником-суперадминистратором нельзя заблокировать.", + "This account is not suspended.": "Этот аккаунт не заблокирован.", + "This account has been suspended.": "Этот аккаунт заблокирован.", + "Your :app_name account has been suspended": "Ваш аккаунт :app_name заблокирован", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ваш аккаунт :appName заблокирован. Ваши мероприятия и страницы организатора сняты с публикации, и вы больше не можете войти в аккаунт.", + "Reason:": "Причина:", + "If you think this is a mistake, contact us at :email.": "Если вы считаете, что это ошибка, напишите нам на :email." } diff --git a/backend/lang/se.json b/backend/lang/se.json index 6fd43ed983..5c3025e109 100644 --- a/backend/lang/se.json +++ b/backend/lang/se.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Endast ett återkommande evenemang med exakt ett datum kan ändras till ett enskilt evenemang. Ta bort de andra datumen först.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Det återstående datumet är inställt. Återaktivera det innan du byter till ett enskilt evenemang.", "A recurring event with orders cannot be changed to a single event.": "Ett återkommande evenemang med beställningar kan inte ändras till ett enskilt evenemang.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Du har redan reserverat biljetter till det här evenemanget. Slutför eller avbryt den beställningen innan du reserverar fler." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Du har redan reserverat biljetter till det här evenemanget. Slutför eller avbryt den beställningen innan du reserverar fler.", + "This account is already suspended.": "Det här kontot är redan avstängt.", + "Accounts with a superadmin member cannot be suspended.": "Konton med en superadmin som medlem kan inte stängas av.", + "This account is not suspended.": "Det här kontot är inte avstängt.", + "This account has been suspended.": "Det här kontot har stängts av.", + "Your :app_name account has been suspended": "Ditt :app_name-konto har stängts av", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Ditt :appName-konto har stängts av. Dina evenemang och arrangörssidor har avpublicerats och du kan inte längre logga in på kontot.", + "Reason:": "Anledning:", + "If you think this is a mistake, contact us at :email.": "Om du tror att det är ett misstag, kontakta oss på :email." } diff --git a/backend/lang/sk.json b/backend/lang/sk.json index d99c49364b..7b7bd48df8 100644 --- a/backend/lang/sk.json +++ b/backend/lang/sk.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Na jednorazové podujatie možno zmeniť iba opakované podujatie s presne jedným dátumom. Najprv odstráňte ostatné dátumy.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Zostávajúci dátum je zrušený. Pred zmenou na jednorazové podujatie ho znova aktivujte.", "A recurring event with orders cannot be changed to a single event.": "Opakované podujatie s objednávkami nemožno zmeniť na jednorazové podujatie.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Na toto podujatie už máte rezervované vstupenky. Pred ďalšou rezerváciou dokončite alebo zrušte túto objednávku." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Na toto podujatie už máte rezervované vstupenky. Pred ďalšou rezerváciou dokončite alebo zrušte túto objednávku.", + "This account is already suspended.": "Tento účet je už pozastavený.", + "Accounts with a superadmin member cannot be suspended.": "Účty s členom superadministrátorom nie je možné pozastaviť.", + "This account is not suspended.": "Tento účet nie je pozastavený.", + "This account has been suspended.": "Tento účet bol pozastavený.", + "Your :app_name account has been suspended": "Váš účet :app_name bol pozastavený", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Váš účet :appName bol pozastavený. Vaše udalosti a stránky organizátora boli stiahnuté zo zverejnenia a do účtu sa už nemôžete prihlásiť.", + "Reason:": "Dôvod:", + "If you think this is a mistake, contact us at :email.": "Ak si myslíte, že ide o omyl, kontaktujte nás na :email." } diff --git a/backend/lang/tr.json b/backend/lang/tr.json index 58d4879c82..3d17deb73c 100644 --- a/backend/lang/tr.json +++ b/backend/lang/tr.json @@ -1219,5 +1219,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Yalnızca tam olarak bir tarihi olan tekrarlanan bir etkinlik tek seferlik etkinliğe dönüştürülebilir. Önce diğer tarihleri silin.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Kalan tarih iptal edildi. Tek seferlik etkinliğe geçmeden önce yeniden etkinleştirin.", "A recurring event with orders cannot be changed to a single event.": "Siparişleri olan tekrarlanan bir etkinlik tek seferlik etkinliğe dönüştürülemez.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Bu etkinlik için zaten rezerve edilmiş biletleriniz var. Daha fazla rezervasyon yapmadan önce o siparişi tamamlayın veya iptal edin." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Bu etkinlik için zaten rezerve edilmiş biletleriniz var. Daha fazla rezervasyon yapmadan önce o siparişi tamamlayın veya iptal edin.", + "This account is already suspended.": "Bu hesap zaten askıya alınmış.", + "Accounts with a superadmin member cannot be suspended.": "Süper yönetici üyesi olan hesaplar askıya alınamaz.", + "This account is not suspended.": "Bu hesap askıya alınmamış.", + "This account has been suspended.": "Bu hesap askıya alındı.", + "Your :app_name account has been suspended": ":app_name hesabınız askıya alındı", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": ":appName hesabınız askıya alındı. Etkinlikleriniz ve organizatör sayfalarınız yayından kaldırıldı ve artık hesaba giriş yapamazsınız.", + "Reason:": "Neden:", + "If you think this is a mistake, contact us at :email.": "Bunun bir hata olduğunu düşünüyorsanız :email adresinden bize ulaşın." } diff --git a/backend/lang/vi.json b/backend/lang/vi.json index a44d4931b1..c1da48acea 100644 --- a/backend/lang/vi.json +++ b/backend/lang/vi.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "Chỉ sự kiện định kỳ có đúng một ngày mới có thể chuyển thành sự kiện đơn lẻ. Hãy xóa các ngày khác trước.", "The remaining date is cancelled. Reactivate it before changing to a single event.": "Ngày còn lại đã bị hủy. Hãy kích hoạt lại trước khi chuyển sang sự kiện đơn lẻ.", "A recurring event with orders cannot be changed to a single event.": "Không thể chuyển sự kiện định kỳ có đơn hàng thành sự kiện đơn lẻ.", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Bạn đã đặt giữ vé cho sự kiện này. Hãy hoàn tất hoặc hủy đơn hàng đó trước khi đặt thêm." + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "Bạn đã đặt giữ vé cho sự kiện này. Hãy hoàn tất hoặc hủy đơn hàng đó trước khi đặt thêm.", + "This account is already suspended.": "Tài khoản này đã bị tạm ngưng.", + "Accounts with a superadmin member cannot be suspended.": "Không thể tạm ngưng tài khoản có thành viên là quản trị viên cấp cao.", + "This account is not suspended.": "Tài khoản này không bị tạm ngưng.", + "This account has been suspended.": "Tài khoản này đã bị tạm ngưng.", + "Your :app_name account has been suspended": "Tài khoản :app_name của bạn đã bị tạm ngưng", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "Tài khoản :appName của bạn đã bị tạm ngưng. Các sự kiện và trang nhà tổ chức của bạn đã bị gỡ xuất bản và bạn không thể đăng nhập vào tài khoản nữa.", + "Reason:": "Lý do:", + "If you think this is a mistake, contact us at :email.": "Nếu bạn cho rằng đây là nhầm lẫn, hãy liên hệ với chúng tôi qua :email." } diff --git a/backend/lang/zh-cn.json b/backend/lang/zh-cn.json index f5e2cf3bdd..86683a72ca 100644 --- a/backend/lang/zh-cn.json +++ b/backend/lang/zh-cn.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "只有恰好有一个日期的周期性活动才能更改为单次活动。请先删除其他日期。", "The remaining date is cancelled. Reactivate it before changing to a single event.": "剩余的日期已取消。请先重新启用,再更改为单次活动。", "A recurring event with orders cannot be changed to a single event.": "有订单的周期性活动无法更改为单次活动。", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "您已为此活动预留了门票。请先完成或取消该订单,然后再预留更多门票。" + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "您已为此活动预留了门票。请先完成或取消该订单,然后再预留更多门票。", + "This account is already suspended.": "此账户已被暂停。", + "Accounts with a superadmin member cannot be suspended.": "包含超级管理员成员的账户无法被暂停。", + "This account is not suspended.": "此账户未被暂停。", + "This account has been suspended.": "此账户已被暂停。", + "Your :app_name account has been suspended": "您的 :app_name 账户已被暂停", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "您的 :appName 账户已被暂停。您的活动和主办方页面已取消发布,您将无法再登录该账户。", + "Reason:": "原因:", + "If you think this is a mistake, contact us at :email.": "如果您认为这是误操作,请通过 :email 联系我们。" } diff --git a/backend/lang/zh-hk.json b/backend/lang/zh-hk.json index 33057fcc0e..76c5ed1b5f 100644 --- a/backend/lang/zh-hk.json +++ b/backend/lang/zh-hk.json @@ -1204,5 +1204,13 @@ "Only a recurring event with exactly one date can be changed to a single event. Delete the other dates first.": "只有恰好有一個日期的週期性活動才能更改為單次活動。請先刪除其他日期。", "The remaining date is cancelled. Reactivate it before changing to a single event.": "剩餘的日期已取消。請先重新啟用,再更改為單次活動。", "A recurring event with orders cannot be changed to a single event.": "有訂單的週期性活動無法更改為單次活動。", - "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "您已為此活動預留了門票。請先完成或取消該訂單,然後再預留更多門票。" + "You already have tickets reserved for this event. Complete or cancel that order before reserving more.": "您已為此活動預留了門票。請先完成或取消該訂單,然後再預留更多門票。", + "This account is already suspended.": "此帳戶已被暫停。", + "Accounts with a superadmin member cannot be suspended.": "包含超級管理員成員的帳戶無法被暫停。", + "This account is not suspended.": "此帳戶未被暫停。", + "This account has been suspended.": "此帳戶已被暫停。", + "Your :app_name account has been suspended": "您的 :app_name 帳戶已被暫停", + "Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.": "您的 :appName 帳戶已被暫停。您的活動和主辦方頁面已取消發佈,您將無法再登入該帳戶。", + "Reason:": "原因:", + "If you think this is a mistake, contact us at :email.": "如果您認為這是誤會,請透過 :email 聯絡我們。" } diff --git a/backend/resources/views/emails/account/suspended.blade.php b/backend/resources/views/emails/account/suspended.blade.php new file mode 100644 index 0000000000..882025f5c9 --- /dev/null +++ b/backend/resources/views/emails/account/suspended.blade.php @@ -0,0 +1,17 @@ +@php /** @var string|null $reason */ @endphp +@php /** @var string $supportEmail */ @endphp + + +{{ __('Hi') }}, + +{{ __('Your :appName account has been suspended. Your events and organizer pages have been unpublished, and you can no longer sign in to the account.', ['appName' => config('app.name')]) }} + +@if ($reason) +{{ __('Reason:') }} {{ $reason }} +@endif + +{{ __('If you think this is a mistake, contact us at :email.', ['email' => $supportEmail]) }} + +{{ __('Best Regards,') }}
+{{ __('The :appName Team', ['appName' => config('app.name')]) }} +
diff --git a/backend/routes/api.php b/backend/routes/api.php index 190af5bbbe..ff99abf013 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -75,6 +75,8 @@ use HiEvents\Http\Actions\Accounts\UpdateAccountTwoFactorRequirementAction; use HiEvents\Http\Actions\Admin\Accounts\GetAccountAction as GetAdminAccountAction; use HiEvents\Http\Actions\Admin\Accounts\GetAllAccountsAction as GetAllAdminAccountsAction; +use HiEvents\Http\Actions\Admin\Accounts\SuspendAccountAction; +use HiEvents\Http\Actions\Admin\Accounts\UnsuspendAccountAction; use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountMessagingTierAction; use HiEvents\Http\Actions\Admin\Accounts\UpdateAccountVerificationAction; use HiEvents\Http\Actions\Admin\Announcements\CreateAnnouncementAction; @@ -865,6 +867,10 @@ function (Router $router): void { // Account Verification $router->put('/accounts/{account_id}/verification', UpdateAccountVerificationAction::class); + // Account Suspension + $router->post('/accounts/{account_id}/suspension', SuspendAccountAction::class)->whereNumber('account_id'); + $router->delete('/accounts/{account_id}/suspension', UnsuspendAccountAction::class)->whereNumber('account_id'); + // Feature Flags $router->get('/licence', GetLicenceAdminAction::class); $router->get('/licence/sales', GetLicenceSalesAdminAction::class); diff --git a/backend/tests/Feature/Http/Actions/Admin/AccountSuspensionTest.php b/backend/tests/Feature/Http/Actions/Admin/AccountSuspensionTest.php new file mode 100644 index 0000000000..0af4f3d83b --- /dev/null +++ b/backend/tests/Feature/Http/Actions/Admin/AccountSuspensionTest.php @@ -0,0 +1,221 @@ +superadmin, $this->superadminAccountId] = $this->createAccountOwner(); + DB::table('account_users') + ->where(['account_id' => $this->superadminAccountId, 'user_id' => $this->superadmin->id]) + ->update(['role' => Role::SUPERADMIN->name]); + + $this->owner = User::factory()->password(self::PASSWORD)->withAccount()->create(); + $this->accountId = $this->owner->accounts()->first()->id; + } + + public function test_only_superadmins_can_suspend_or_unsuspend(): void + { + [$admin, $adminAccountId] = $this->createAccountOwner(); + + $this->postJson("/admin/accounts/{$this->accountId}/suspension", [], $this->headersFor($admin, $adminAccountId)) + ->assertForbidden(); + $this->deleteJson("/admin/accounts/{$this->accountId}/suspension", [], $this->headersFor($admin, $adminAccountId)) + ->assertForbidden(); + + $this->assertNull(DB::table('accounts')->where('id', $this->accountId)->value('suspended_at')); + } + + public function test_suspending_unpublishes_events_and_organizers(): void + { + $organizerId = $this->createOrganizer($this->accountId); + DB::table('organizers')->where('id', $organizerId)->update(['status' => 'LIVE']); + $liveEventId = $this->createEventWithStatus($organizerId, 'LIVE'); + $reviewEventId = $this->createEventWithStatus($organizerId, 'PENDING_MANUAL_REVIEW'); + $archivedEventId = $this->createEventWithStatus($organizerId, 'ARCHIVED'); + + $this->suspend(['reason' => 'Phishing']) + ->assertOk() + ->assertJsonPath('data.suspension_reason', 'Phishing') + ->assertJsonPath('data.suspended_at', fn ($value) => $value !== null); + + $this->assertSame('DRAFT', DB::table('events')->where('id', $liveEventId)->value('status')); + $this->assertSame('DRAFT', DB::table('events')->where('id', $reviewEventId)->value('status')); + $this->assertSame('ARCHIVED', DB::table('events')->where('id', $archivedEventId)->value('status')); + $this->assertSame('DRAFT', DB::table('organizers')->where('id', $organizerId)->value('status')); + Mail::assertNothingQueued(); + } + + public function test_the_account_owner_is_emailed_only_when_asked(): void + { + $this->suspend(['reason' => 'Phishing', 'notify_account_owner' => true])->assertOk(); + + $accountEmail = DB::table('accounts')->where('id', $this->accountId)->value('email'); + + Mail::assertQueued(AccountSuspendedEmail::class, fn (AccountSuspendedEmail $mail) => $mail->hasTo($accountEmail)); + } + + public function test_an_account_cannot_be_suspended_twice(): void + { + $this->suspend()->assertOk(); + + $this->suspend()->assertStatus(409); + } + + public function test_accounts_with_a_superadmin_member_cannot_be_suspended(): void + { + $this->postJson( + "/admin/accounts/{$this->superadminAccountId}/suspension", + [], + $this->headersFor($this->superadmin, $this->superadminAccountId), + )->assertStatus(409); + + $this->assertNull(DB::table('accounts')->where('id', $this->superadminAccountId)->value('suspended_at')); + } + + public function test_existing_sessions_are_blocked_on_signed_in_routes(): void + { + $this->suspend()->assertOk(); + + $this->getJson('/events', $this->headersFor($this->owner, $this->accountId)) + ->assertForbidden() + ->assertJsonPath('error_code', 'ACCOUNT_SUSPENDED'); + $this->getJson('/users/me', $this->headersFor($this->owner, $this->accountId)) + ->assertForbidden() + ->assertJsonPath('error_code', 'ACCOUNT_SUSPENDED'); + } + + public function test_public_routes_treat_a_suspended_session_as_anonymous(): void + { + $organizerId = $this->createOrganizer($this->accountId); + $eventId = $this->createEventWithStatus($organizerId, 'LIVE'); + $this->suspend()->assertOk(); + + $this->getJson("/public/events/{$eventId}", $this->headersFor($this->owner, $this->accountId)) + ->assertNotFound(); + } + + public function test_api_keys_are_blocked(): void + { + $apiKey = $this->createApiKey($this->owner, $this->accountId); + $this->suspend()->assertOk(); + + $this->getJson('/organizers', $this->apiKeyHeaders($apiKey)) + ->assertForbidden() + ->assertJsonPath('error_code', 'ACCOUNT_SUSPENDED'); + } + + public function test_impersonating_admins_can_still_use_the_account(): void + { + $this->suspend()->assertOk(); + + $this->app['auth']->forgetGuards(); + $this->app['tymon.jwt']->unsetToken(); + $token = JWTAuth::claims([ + 'account_id' => $this->accountId, + 'is_impersonating' => true, + 'impersonator_id' => $this->superadmin->id, + ])->fromUser($this->owner); + + $this->getJson('/events', ['Authorization' => 'Bearer '.$token])->assertOk(); + } + + public function test_members_of_a_suspended_account_cannot_sign_in(): void + { + $this->suspend()->assertOk(); + + $this->postJson('/auth/login', ['email' => $this->owner->email, 'password' => self::PASSWORD]) + ->assertUnauthorized() + ->assertJsonPath('error_code', 'ACCOUNT_SUSPENDED'); + } + + public function test_members_of_several_accounts_sign_in_to_the_one_that_is_not_suspended(): void + { + [, $otherAccountId] = $this->createAccountOwner(); + $this->owner->accounts()->attach($otherAccountId, [ + 'role' => Role::ADMIN->value, + 'status' => 'ACTIVE', + 'is_account_owner' => false, + ]); + $this->suspend()->assertOk(); + + $response = $this->postJson('/auth/login', ['email' => $this->owner->email, 'password' => self::PASSWORD]) + ->assertOk(); + + $this->assertSame([$otherAccountId], collect($response->json('accounts'))->pluck('id')->all()); + $this->assertNotNull($response->json('token')); + } + + public function test_unsuspending_restores_access_but_leaves_events_in_draft(): void + { + $organizerId = $this->createOrganizer($this->accountId); + $eventId = $this->createEventWithStatus($organizerId, 'LIVE'); + $this->suspend(['reason' => 'Phishing'])->assertOk(); + + $this->deleteJson("/admin/accounts/{$this->accountId}/suspension", [], $this->superadminHeaders()) + ->assertOk() + ->assertJsonPath('data.suspended_at', null) + ->assertJsonPath('data.suspension_reason', null); + + $this->getJson('/events', $this->headersFor($this->owner, $this->accountId))->assertOk(); + $this->postJson('/auth/login', ['email' => $this->owner->email, 'password' => self::PASSWORD])->assertOk(); + $this->assertSame('DRAFT', DB::table('events')->where('id', $eventId)->value('status')); + } + + public function test_unsuspending_an_active_account_conflicts(): void + { + $this->deleteJson("/admin/accounts/{$this->accountId}/suspension", [], $this->superadminHeaders()) + ->assertStatus(409); + } + + private function suspend(array $payload = []): TestResponse + { + return $this->postJson("/admin/accounts/{$this->accountId}/suspension", $payload, $this->superadminHeaders()); + } + + private function superadminHeaders(): array + { + return $this->headersFor($this->superadmin, $this->superadminAccountId); + } + + private function createEventWithStatus(int $organizerId, string $status): int + { + $eventId = $this->createEvent($this->accountId, $organizerId); + DB::table('events')->where('id', $eventId)->update(['status' => $status]); + + return $eventId; + } +} diff --git a/backend/tests/Feature/Http/TeamAccess/TeamInviteTest.php b/backend/tests/Feature/Http/TeamAccess/TeamInviteTest.php index 6ccc841be3..15893cb429 100644 --- a/backend/tests/Feature/Http/TeamAccess/TeamInviteTest.php +++ b/backend/tests/Feature/Http/TeamAccess/TeamInviteTest.php @@ -267,7 +267,7 @@ public function test_listing_members_does_not_query_per_member(): void DB::disableQueryLog(); $this->assertCount(11, $members); - $this->assertLessThanOrEqual(11, count(DB::getQueryLog())); + $this->assertLessThanOrEqual(12, count(DB::getQueryLog())); } public function test_inviting_an_existing_user_in_a_different_case_reuses_their_account_and_hides_their_private_details(): void diff --git a/e2e/pages/admin.page.ts b/e2e/pages/admin.page.ts index a4737eb43b..68d47ec582 100644 --- a/e2e/pages/admin.page.ts +++ b/e2e/pages/admin.page.ts @@ -71,6 +71,12 @@ export class AdminPage { await this.page.getByTestId('admin-delete-account-confirm-button').click(); } + async suspendAccount(reason: string): Promise { + await this.page.getByTestId('admin-suspend-account-button').click(); + await this.page.getByLabel('Reason').fill(reason); + await this.page.getByTestId('admin-suspend-account-confirm-button').click(); + } + async gotoLifecycleEmails(): Promise { await this.page.goto('/admin/lifecycle-emails'); await this.page.waitForLoadState('networkidle'); diff --git a/e2e/tests/admin/account-suspension.spec.ts b/e2e/tests/admin/account-suspension.spec.ts new file mode 100644 index 0000000000..70614516b1 --- /dev/null +++ b/e2e/tests/admin/account-suspension.spec.ts @@ -0,0 +1,22 @@ +import { test, expect } from '../../fixtures'; +import { AdminPage } from '../../pages/admin.page'; + +test.describe('admin account suspension', () => { + test('a superadmin suspends an account and unsuspends it', { tag: '@admin' }, async ({ superAdminPage, freshAccount, adminApi }) => { + const accountId = await adminApi.findAccountIdByEmail(freshAccount.email); + + const admin = new AdminPage(superAdminPage); + await admin.gotoAccount(accountId); + + await admin.suspendAccount('E2E suspension'); + + await expect(superAdminPage.getByText('Account suspended', { exact: true })).toBeVisible(); + await expect(superAdminPage.getByText('Reason: E2E suspension')).toBeVisible(); + + await superAdminPage.getByTestId('admin-unsuspend-account-button').click(); + await superAdminPage.getByRole('button', { name: 'Unsuspend', exact: true }).click(); + + await expect(superAdminPage.getByText('Account unsuspended')).toBeVisible(); + await expect(superAdminPage.getByTestId('admin-suspend-account-button')).toBeVisible(); + }); +}); diff --git a/frontend/src/api/admin.client.ts b/frontend/src/api/admin.client.ts index 90164fbd81..2582a45b62 100644 --- a/frontend/src/api/admin.client.ts +++ b/frontend/src/api/admin.client.ts @@ -35,6 +35,7 @@ export interface AdminAccount { timezone?: string; currency_code?: string; created_at: string; + suspended_at: string | null; events_count: number; users_count: number; users: AdminAccountUser[]; @@ -109,6 +110,7 @@ export interface AdminAccountDetail extends AdminAccount { messaging_tier?: AccountMessagingTier; organizers: AdminOrganizerSummary[]; is_manually_verified: boolean; + suspension_reason: string | null; active_deletion_request: { id: IdParam; scheduled_deletion_at: string; @@ -117,6 +119,11 @@ export interface AdminAccountDetail extends AdminAccount { lifecycle_nudges?: AdminLifecycleNudge[]; } +export interface AdminSuspendAccountPayload { + reason?: string; + notify_account_owner: boolean; +} + export interface AdminRequestAccountDeletionPayload { reason?: string; execute_immediately: boolean; @@ -856,6 +863,16 @@ export const adminClient = { return response.data; }, + suspendAccount: async (accountId: IdParam, payload: AdminSuspendAccountPayload) => { + const response = await api.post>(`admin/accounts/${accountId}/suspension`, payload); + return response.data; + }, + + unsuspendAccount: async (accountId: IdParam) => { + const response = await api.delete>(`admin/accounts/${accountId}/suspension`); + return response.data; + }, + requestAccountDeletion: async (accountId: IdParam, payload: AdminRequestAccountDeletionPayload) => { const response = await api.post(`admin/accounts/${accountId}/deletion-request`, payload); return response.data; diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 437833561f..54ede0c1d9 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -37,8 +37,8 @@ api.interceptors.response.use( } if (action.type === 'login') { - if (data?.error_code === 'SSO_REQUIRED') { - setAuthNotice('SSO_REQUIRED'); + if (data?.error_code === 'SSO_REQUIRED' || data?.error_code === 'ACCOUNT_SUSPENDED') { + setAuthNotice(data.error_code); } else if (status === 401) { queueSilentReauthAfterExpiry(); } diff --git a/frontend/src/components/common/AdminAccountsTable/index.tsx b/frontend/src/components/common/AdminAccountsTable/index.tsx index 2651a6992a..def759b38e 100644 --- a/frontend/src/components/common/AdminAccountsTable/index.tsx +++ b/frontend/src/components/common/AdminAccountsTable/index.tsx @@ -1,4 +1,4 @@ -import {Badge, Button, Stack, Text} from "@mantine/core"; +import {Badge, Button, Group, Stack, Text} from "@mantine/core"; import {t} from "@lingui/macro"; import {AdminAccount} from "../../../api/admin.client"; import {IconCalendar, IconWorld, IconBuildingBank, IconUsers, IconEye, IconMessage} from "@tabler/icons-react"; @@ -61,7 +61,10 @@ const AdminAccountsTable = ({accounts, onImpersonate, isLoading}: AdminAccountsT
-

{account.name}

+ +

{account.name}

+ {account.suspended_at && {t`Suspended`}} +
{account.email}