diff --git a/backend/.env.example b/backend/.env.example index c5ee655b31..d2425b9a3b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -11,6 +11,8 @@ APP_SAAS_MODE_ENABLED=false APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=1.5 APP_HOMEPAGE_VIEWS_UPDATE_BATCH_SIZE=8 APP_DISABLE_REGISTRATION=false +APP_REQUIRE_ACCOUNT_APPROVAL=false +APP_ADMIN_EMAIL= APP_STRIPE_CONNECT_ACCOUNT_TYPE=express APP_PLATFORM_SUPPORT_EMAIL=support@your-website.com APP_ALLOWED_INTERNAL_WEBHOOK_HOSTS= diff --git a/backend/app/DomainObjects/AccountEmailSettingDomainObject.php b/backend/app/DomainObjects/AccountEmailSettingDomainObject.php new file mode 100644 index 0000000000..53467aa034 --- /dev/null +++ b/backend/app/DomainObjects/AccountEmailSettingDomainObject.php @@ -0,0 +1,7 @@ + $this->short_id ?? null, 'stripe_connect_setup_complete' => $this->stripe_connect_setup_complete ?? null, 'account_verified_at' => $this->account_verified_at ?? null, + 'approved_at' => $this->approved_at ?? null, 'stripe_connect_account_type' => $this->stripe_connect_account_type ?? null, 'is_manually_verified' => $this->is_manually_verified ?? null, 'country' => $this->country ?? null, @@ -223,6 +226,18 @@ public function getAccountVerifiedAt(): ?string return $this->account_verified_at; } + + public function setApprovedAt(?string $approved_at): self + { + $this->approved_at = $approved_at; + return $this; + } + + public function getApprovedAt(): ?string + { + return $this->approved_at; + } + public function setStripeConnectAccountType(?string $stripe_connect_account_type): self { $this->stripe_connect_account_type = $stripe_connect_account_type; diff --git a/backend/app/DomainObjects/Generated/AccountEmailSettingDomainObjectAbstract.php b/backend/app/DomainObjects/Generated/AccountEmailSettingDomainObjectAbstract.php new file mode 100644 index 0000000000..46a50cd907 --- /dev/null +++ b/backend/app/DomainObjects/Generated/AccountEmailSettingDomainObjectAbstract.php @@ -0,0 +1,202 @@ + $this->id ?? null, + 'account_id' => $this->account_id ?? null, + 'smtp_enabled' => $this->smtp_enabled ?? null, + 'smtp_host' => $this->smtp_host ?? null, + 'smtp_port' => $this->smtp_port ?? null, + 'smtp_encryption' => $this->smtp_encryption ?? null, + 'smtp_username' => $this->smtp_username ?? null, + 'smtp_password' => $this->smtp_password ?? null, + 'mail_from_address' => $this->mail_from_address ?? null, + 'mail_from_name' => $this->mail_from_name ?? null, + 'created_at' => $this->created_at ?? null, + 'updated_at' => $this->updated_at ?? null, + 'deleted_at' => $this->deleted_at ?? null, + ]; + } + + public function setId(int $id): self + { + $this->id = $id; + return $this; + } + + public function getId(): int + { + return $this->id; + } + + public function setAccountId(int $account_id): self + { + $this->account_id = $account_id; + return $this; + } + + public function getAccountId(): int + { + return $this->account_id; + } + + public function setSmtpEnabled(bool $smtp_enabled): self + { + $this->smtp_enabled = $smtp_enabled; + return $this; + } + + public function getSmtpEnabled(): bool + { + return $this->smtp_enabled; + } + + public function setSmtpHost(?string $smtp_host): self + { + $this->smtp_host = $smtp_host; + return $this; + } + + public function getSmtpHost(): ?string + { + return $this->smtp_host; + } + + public function setSmtpPort(?int $smtp_port): self + { + $this->smtp_port = $smtp_port; + return $this; + } + + public function getSmtpPort(): ?int + { + return $this->smtp_port; + } + + public function setSmtpEncryption(?string $smtp_encryption): self + { + $this->smtp_encryption = $smtp_encryption; + return $this; + } + + public function getSmtpEncryption(): ?string + { + return $this->smtp_encryption; + } + + public function setSmtpUsername(?string $smtp_username): self + { + $this->smtp_username = $smtp_username; + return $this; + } + + public function getSmtpUsername(): ?string + { + return $this->smtp_username; + } + + public function setSmtpPassword(?string $smtp_password): self + { + $this->smtp_password = $smtp_password; + return $this; + } + + public function getSmtpPassword(): ?string + { + return $this->smtp_password; + } + + public function setMailFromAddress(?string $mail_from_address): self + { + $this->mail_from_address = $mail_from_address; + return $this; + } + + public function getMailFromAddress(): ?string + { + return $this->mail_from_address; + } + + public function setMailFromName(?string $mail_from_name): self + { + $this->mail_from_name = $mail_from_name; + return $this; + } + + public function getMailFromName(): ?string + { + return $this->mail_from_name; + } + + public function setCreatedAt(?string $created_at): self + { + $this->created_at = $created_at; + return $this; + } + + public function getCreatedAt(): ?string + { + return $this->created_at; + } + + public function setUpdatedAt(?string $updated_at): self + { + $this->updated_at = $updated_at; + return $this; + } + + public function getUpdatedAt(): ?string + { + return $this->updated_at; + } + + public function setDeletedAt(?string $deleted_at): self + { + $this->deleted_at = $deleted_at; + return $this; + } + + public function getDeletedAt(): ?string + { + return $this->deleted_at; + } +} diff --git a/backend/app/Http/Actions/Accounts/Approval/ApproveAccountAction.php b/backend/app/Http/Actions/Accounts/Approval/ApproveAccountAction.php new file mode 100644 index 0000000000..7db8623a69 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Approval/ApproveAccountAction.php @@ -0,0 +1,88 @@ +query('token'); + + if (!$token) { + return response()->json(['message' => __('Missing approval token.')], 400); + } + + try { + $payload = $this->encryptedPayloadService->decryptPayload($token); + } catch (DecryptionFailedException|EncryptedPayloadExpiredException $e) { + $this->logger->warning('Invalid or expired account approval token', [ + 'error' => $e->getMessage(), + ]); + return response()->json([ + 'message' => __('This approval link is invalid or has expired.'), + ], 400); + } + + $accountId = $payload['account_id'] ?? null; + if (!$accountId) { + return response()->json(['message' => __('Invalid token payload.')], 400); + } + + $account = $this->accountRepository->findById($accountId); + if (!$account) { + return response()->json(['message' => __('Account not found.')], 404); + } + + if ($account->getApprovedAt() !== null) { + return response()->json(['message' => __('This account has already been approved.')], 200); + } + + // Approve the account + $this->accountRepository->updateWhere( + attributes: ['approved_at' => now()->toDateTimeString()], + where: ['id' => $accountId], + ); + + $this->logger->info('Account approved by admin', ['account_id' => $accountId]); + + // Find the account owner and send them their confirmation email + $accountOwner = $this->accountUserRepository->findFirstWhere([ + 'account_id' => $accountId, + 'is_account_owner' => true, + ]); + + if ($accountOwner) { + $user = $this->userRepository->findById($accountOwner->getUserId()); + $this->emailConfirmationService->sendConfirmation($user, $accountId); + } + + return response()->json([ + 'message' => __('Account approved successfully. The user has been notified.'), + ], 200); + } +} diff --git a/backend/app/Http/Actions/Accounts/Approval/ResendApprovalRequestAction.php b/backend/app/Http/Actions/Accounts/Approval/ResendApprovalRequestAction.php new file mode 100644 index 0000000000..3a07447a2c --- /dev/null +++ b/backend/app/Http/Actions/Accounts/Approval/ResendApprovalRequestAction.php @@ -0,0 +1,92 @@ +validate([ + 'email' => 'required|email', + ]); + + $email = strtolower($validated['email']); + + // Find the user + $user = $this->userRepository->findFirstWhere(['email' => $email]); + if (!$user) { + // Don't reveal whether the email exists + return response()->json([ + 'message' => __('If an account with this email exists and is pending approval, a new approval request has been sent to the administrator.'), + ], 200); + } + + // Find their account(s) that are pending approval + $accountUsers = $this->accountUserRepository->findWhere([ + 'user_id' => $user->getId(), + 'is_account_owner' => true, + ]); + + $sentToAdmin = false; + foreach ($accountUsers as $accountUser) { + $account = $this->accountRepository->findById($accountUser->getAccountId()); + + if ($account && $account->getApprovedAt() === null) { + $this->sendApprovalEmail($user, $account); + $sentToAdmin = true; + } + } + + if ($sentToAdmin) { + $this->logger->info('Resent approval request to admin', ['email' => $email]); + } + + return response()->json([ + 'message' => __('If an account with this email exists and is pending approval, a new approval request has been sent to the administrator.'), + ], 200); + } + + private function sendApprovalEmail($user, $account): void + { + $adminEmail = config('app.admin_email'); + if (!$adminEmail) { + $this->logger->error('APP_ADMIN_EMAIL not configured'); + return; + } + + $token = $this->encryptedPayloadService->encryptPayload([ + 'account_id' => $account->getId(), + ], Carbon::now()->addDays(30)); + + $approveUrl = config('app.frontend_url') . '/admin/approve-account?token=' . urlencode($token); + + $this->mailer + ->to($adminEmail) + ->send(new AccountApprovalRequestEmail($user, $account, $approveUrl)); + } +} diff --git a/backend/app/Http/Actions/Accounts/CreateAccountAction.php b/backend/app/Http/Actions/Accounts/CreateAccountAction.php index b7136554be..bc05629504 100644 --- a/backend/app/Http/Actions/Accounts/CreateAccountAction.php +++ b/backend/app/Http/Actions/Accounts/CreateAccountAction.php @@ -82,6 +82,14 @@ public function __invoke(CreateAccountRequest $request): JsonResponse ); } + // If account approval is required, don't auto-login — return a pending message + if (config('app.require_account_approval') && $accountData->getApprovedAt() === null) { + return $this->jsonResponse([ + 'message' => __('Your account has been created and is pending admin approval. You will receive an email once approved.'), + 'pending_approval' => true, + ], ResponseCodes::HTTP_CREATED); + } + try { $loginResponse = $this->loginHandler->handle(new LoginCredentialsDTO( email: $accountData->getEmail(), diff --git a/backend/app/Http/Actions/Accounts/EmailSetting/GetAccountEmailSettingAction.php b/backend/app/Http/Actions/Accounts/EmailSetting/GetAccountEmailSettingAction.php new file mode 100644 index 0000000000..288929f1e9 --- /dev/null +++ b/backend/app/Http/Actions/Accounts/EmailSetting/GetAccountEmailSettingAction.php @@ -0,0 +1,36 @@ +minimumAllowedRole(Role::ADMIN); + + if ($accountId !== $this->getAuthenticatedAccountId()) { + return $this->errorResponse(__('Unauthorized')); + } + + $setting = $this->handler->handle($accountId); + + if (!$setting) { + return $this->jsonResponse(['data' => null]); + } + + return $this->resourceResponse(AccountEmailSettingResource::class, $setting); + } +} diff --git a/backend/app/Http/Actions/Accounts/EmailSetting/UpsertAccountEmailSettingAction.php b/backend/app/Http/Actions/Accounts/EmailSetting/UpsertAccountEmailSettingAction.php new file mode 100644 index 0000000000..f65469ec4a --- /dev/null +++ b/backend/app/Http/Actions/Accounts/EmailSetting/UpsertAccountEmailSettingAction.php @@ -0,0 +1,56 @@ +minimumAllowedRole(Role::ADMIN); + + if ($accountId !== $this->getAuthenticatedAccountId()) { + return $this->errorResponse(__('Unauthorized')); + } + + $validated = $request->validate([ + 'smtp_enabled' => 'required|boolean', + 'smtp_host' => ['nullable', 'string', 'max:255', new NotInternalHost()], + 'smtp_port' => 'nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'nullable|string|in:tls,ssl,starttls', + 'smtp_username' => 'nullable|string|max:255', + 'smtp_password' => 'nullable|string|max:255', + 'mail_from_address' => 'nullable|email|max:255', + 'mail_from_name' => 'nullable|string|max:255', + ]); + + $setting = $this->handler->handle(new UpsertAccountEmailSettingDTO( + accountId: $accountId, + smtpEnabled: $validated['smtp_enabled'], + smtpHost: $validated['smtp_host'] ?? null, + smtpPort: isset($validated['smtp_port']) ? (int) $validated['smtp_port'] : null, + smtpEncryption: $validated['smtp_encryption'] ?? null, + smtpUsername: $validated['smtp_username'] ?? null, + smtpPassword: $validated['smtp_password'] ?? null, + mailFromAddress: $validated['mail_from_address'] ?? null, + mailFromName: $validated['mail_from_name'] ?? null, + )); + + return $this->resourceResponse(AccountEmailSettingResource::class, $setting); + } +} diff --git a/backend/app/Jobs/Event/SendEventEmailJob.php b/backend/app/Jobs/Event/SendEventEmailJob.php index af6b259ffb..292ccb0f28 100644 --- a/backend/app/Jobs/Event/SendEventEmailJob.php +++ b/backend/app/Jobs/Event/SendEventEmailJob.php @@ -7,6 +7,7 @@ use HiEvents\Mail\Event\EventMessage; use HiEvents\Repository\Interfaces\OutgoingMessageRepositoryInterface; use HiEvents\Services\Application\Handlers\Message\DTO\SendMessageDTO; +use HiEvents\Services\Domain\Mail\AccountMailerFactory; use Illuminate\Bus\Queueable; use Illuminate\Contracts\Queue\ShouldQueue; use Illuminate\Foundation\Bus\Dispatchable; @@ -24,6 +25,7 @@ public function __construct( private readonly string $toName, private readonly EventMessage $eventMessage, private readonly SendMessageDTO $messageData, + private readonly int $accountId, ) { } @@ -34,10 +36,13 @@ public function __construct( public function handle( Mailer $mailer, OutgoingMessageRepositoryInterface $outgoingMessageRepository, + AccountMailerFactory $accountMailerFactory, ): void { + $resolvedMailer = $accountMailerFactory->forAccount($this->accountId); + try { - $mailer + $resolvedMailer ->to($this->email, $this->toName) ->send($this->eventMessage); } catch (Throwable $exception) { diff --git a/backend/app/Mail/Account/AccountApprovalRequestEmail.php b/backend/app/Mail/Account/AccountApprovalRequestEmail.php new file mode 100644 index 0000000000..1a5a45eb2a --- /dev/null +++ b/backend/app/Mail/Account/AccountApprovalRequestEmail.php @@ -0,0 +1,42 @@ + $this->user->getEmail(), + ]), + ); + } + + public function content(): Content + { + return new Content( + markdown: 'emails.admin.account-approval-request', + with: [ + 'user' => $this->user, + 'account' => $this->account, + 'approveUrl' => $this->approveUrl, + ] + ); + } +} diff --git a/backend/app/Models/Account.php b/backend/app/Models/Account.php index 9d470392bd..31f7e8b1a4 100644 --- a/backend/app/Models/Account.php +++ b/backend/app/Models/Account.php @@ -52,6 +52,11 @@ public function account_vat_setting(): HasOne return $this->hasOne(AccountVatSetting::class); } + public function account_email_setting(): HasOne + { + return $this->hasOne(AccountEmailSetting::class); + } + public function messagingTier(): BelongsTo { return $this->belongsTo(AccountMessagingTier::class, 'account_messaging_tier_id'); diff --git a/backend/app/Models/AccountEmailSetting.php b/backend/app/Models/AccountEmailSetting.php new file mode 100644 index 0000000000..8eddb815e1 --- /dev/null +++ b/backend/app/Models/AccountEmailSetting.php @@ -0,0 +1,57 @@ + 'boolean', + 'smtp_port' => 'integer', + 'smtp_password' => 'encrypted', + ]; + } + + public function account(): BelongsTo + { + return $this->belongsTo(Account::class); + } +} diff --git a/backend/app/Providers/RepositoryServiceProvider.php b/backend/app/Providers/RepositoryServiceProvider.php index 55f77ef5b6..b1316bfdf3 100644 --- a/backend/app/Providers/RepositoryServiceProvider.php +++ b/backend/app/Providers/RepositoryServiceProvider.php @@ -10,6 +10,7 @@ use HiEvents\Repository\Eloquent\AccountRepository; use HiEvents\Repository\Eloquent\AccountStripePlatformRepository; use HiEvents\Repository\Eloquent\AccountUserRepository; +use HiEvents\Repository\Eloquent\AccountEmailSettingRepository; use HiEvents\Repository\Eloquent\AccountVatSettingRepository; use HiEvents\Repository\Eloquent\AffiliateRepository; use HiEvents\Repository\Eloquent\AttendeeCheckInRepository; @@ -57,6 +58,7 @@ use HiEvents\Repository\Interfaces\AccountRepositoryInterface; use HiEvents\Repository\Interfaces\AccountStripePlatformRepositoryInterface; use HiEvents\Repository\Interfaces\AccountUserRepositoryInterface; +use HiEvents\Repository\Interfaces\AccountEmailSettingRepositoryInterface; use HiEvents\Repository\Interfaces\AccountVatSettingRepositoryInterface; use HiEvents\Repository\Interfaces\AffiliateRepositoryInterface; use HiEvents\Repository\Interfaces\AttendeeCheckInRepositoryInterface; @@ -150,6 +152,7 @@ class RepositoryServiceProvider extends ServiceProvider EmailTemplateRepositoryInterface::class => EmailTemplateRepository::class, AccountStripePlatformRepositoryInterface::class => AccountStripePlatformRepository::class, AccountVatSettingRepositoryInterface::class => AccountVatSettingRepository::class, + AccountEmailSettingRepositoryInterface::class => AccountEmailSettingRepository::class, TicketLookupTokenRepositoryInterface::class => TicketLookupTokenRepository::class, AccountMessagingTierRepositoryInterface::class => AccountMessagingTierRepository::class, WaitlistEntryRepositoryInterface::class => WaitlistEntryRepository::class, diff --git a/backend/app/Repository/Eloquent/AccountEmailSettingRepository.php b/backend/app/Repository/Eloquent/AccountEmailSettingRepository.php new file mode 100644 index 0000000000..d6b68d93fa --- /dev/null +++ b/backend/app/Repository/Eloquent/AccountEmailSettingRepository.php @@ -0,0 +1,28 @@ + + */ +class AccountEmailSettingRepository extends BaseRepository implements AccountEmailSettingRepositoryInterface +{ + protected function getModel(): string + { + return AccountEmailSetting::class; + } + + public function getDomainObject(): string + { + return AccountEmailSettingDomainObject::class; + } + + public function findByAccountId(int $accountId): ?AccountEmailSettingDomainObject + { + return $this->findFirstWhere(['account_id' => $accountId]); + } +} diff --git a/backend/app/Repository/Interfaces/AccountEmailSettingRepositoryInterface.php b/backend/app/Repository/Interfaces/AccountEmailSettingRepositoryInterface.php new file mode 100644 index 0000000000..a1bb6b4bf8 --- /dev/null +++ b/backend/app/Repository/Interfaces/AccountEmailSettingRepositoryInterface.php @@ -0,0 +1,13 @@ + + */ +interface AccountEmailSettingRepositoryInterface extends RepositoryInterface +{ + public function findByAccountId(int $accountId): ?AccountEmailSettingDomainObject; +} diff --git a/backend/app/Resources/Account/AccountEmailSettingResource.php b/backend/app/Resources/Account/AccountEmailSettingResource.php new file mode 100644 index 0000000000..7e5266c3e5 --- /dev/null +++ b/backend/app/Resources/Account/AccountEmailSettingResource.php @@ -0,0 +1,34 @@ + $this->getId(), + 'account_id' => $this->getAccountId(), + 'smtp_enabled' => $this->getSmtpEnabled(), + 'smtp_host' => $this->getSmtpHost(), + 'smtp_port' => $this->getSmtpPort(), + 'smtp_encryption' => $this->getSmtpEncryption(), + 'smtp_username' => $this->getSmtpUsername(), + // Never expose the password back to the client; send a boolean so the + // frontend knows whether one is stored. + 'smtp_password_set' => $this->getSmtpPassword() !== null && $this->getSmtpPassword() !== '', + 'mail_from_address' => $this->getMailFromAddress(), + 'mail_from_name' => $this->getMailFromName(), + 'created_at' => $this->getCreatedAt(), + 'updated_at' => $this->getUpdatedAt(), + ]; + } +} diff --git a/backend/app/Rules/NotInternalHost.php b/backend/app/Rules/NotInternalHost.php new file mode 100644 index 0000000000..cc5b0d2355 --- /dev/null +++ b/backend/app/Rules/NotInternalHost.php @@ -0,0 +1,85 @@ +isPrivateIp($host)) { + $fail(__('This host resolves to a private or internal IP address and is not allowed.')); + return; + } + } + + // Resolve the hostname to IP(s) and check each + $ips = gethostbynamel($host); + if ($ips === false) { + // Can't resolve — allow it; SMTP will fail at send time if truly invalid. + return; + } + + foreach ($ips as $ip) { + if ($this->isPrivateIp($ip)) { + $fail(__('This host resolves to a private or internal IP address and is not allowed.')); + return; + } + } + } + + private function isPrivateIp(string $ip): bool + { + // PHP's built-in filter catches RFC1918, loopback, and reserved ranges + if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) === false) { + return true; + } + + // Additional: 169.254.x.x (link-local / AWS metadata) + if (str_starts_with($ip, '169.254.')) { + return true; + } + + // Additional: 100.64.0.0/10 (CGNAT) + $long = ip2long($ip); + if ($long !== false) { + $cgnatStart = ip2long('100.64.0.0'); + $cgnatEnd = ip2long('100.127.255.255'); + if ($long >= $cgnatStart && $long <= $cgnatEnd) { + return true; + } + } + + return false; + } +} diff --git a/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php index bc5d89e350..f3ba3858b3 100644 --- a/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php +++ b/backend/app/Services/Application/Handlers/Account/CreateAccountHandler.php @@ -19,7 +19,11 @@ use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountConfigurationDoesNotExist; use HiEvents\Services\Application\Handlers\Account\Exceptions\AccountRegistrationDisabledException; use HiEvents\Services\Domain\Account\AccountUserAssociationService; +use HiEvents\Mail\Account\AccountApprovalRequestEmail; use HiEvents\Services\Domain\User\EmailConfirmationService; +use HiEvents\Services\Infrastructure\Encryption\EncryptedPayloadService; +use Illuminate\Contracts\Mail\Mailer; +use Carbon\Carbon; use Illuminate\Config\Repository; use Illuminate\Database\DatabaseManager; use Illuminate\Hashing\HashManager; @@ -41,6 +45,8 @@ public function __construct( private readonly AccountConfigurationRepositoryInterface $accountConfigurationRepository, private readonly AccountAttributionRepositoryInterface $accountAttributionRepository, private readonly LoggerInterface $logger, + private readonly EncryptedPayloadService $encryptedPayloadService, + private readonly Mailer $mailer, ) { } @@ -64,7 +70,8 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject 'name' => $accountData->first_name . ($accountData->last_name ? ' ' . $accountData->last_name : ''), 'email' => strtolower($accountData->email), 'short_id' => IdHelper::shortId(IdHelper::ACCOUNT_PREFIX), - 'account_verified_at' => $isSaasMode ? null : now()->toDateTimeString(), + 'account_verified_at' => ($isSaasMode || $this->requiresApproval()) ? null : now()->toDateTimeString(), + 'approved_at' => $this->requiresApproval() ? null : now()->toDateTimeString(), 'account_configuration_id' => $this->getAccountConfigurationId($accountData), 'account_messaging_tier_id' => $this->getDefaultMessagingTierId(), ]); @@ -75,7 +82,7 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject 'first_name' => $accountData->first_name, 'last_name' => $accountData->last_name, 'timezone' => $this->getTimezone($accountData), - 'email_verified_at' => $isSaasMode ? null : now()->toDateTimeString(), + 'email_verified_at' => ($isSaasMode || $this->requiresApproval()) ? null : now()->toDateTimeString(), 'locale' => $accountData->locale, 'marketing_opted_in_at' => $accountData->marketing_opt_in ? now()->toDateTimeString() : null, ]); @@ -105,7 +112,11 @@ public function handle(CreateAccountDTO $accountData): AccountDomainObject ]); } - $this->emailConfirmationService->sendConfirmation($user, $account->getId()); + if ($this->requiresApproval()) { + $this->sendApprovalRequestToAdmin($user, $account); + } else { + $this->emailConfirmationService->sendConfirmation($user, $account->getId()); + } return $account; }); @@ -265,4 +276,33 @@ private function getDefaultMessagingTierId(): int // Self-hosted instances get Premium tier, SaaS gets Untrusted return $this->config->get('app.is_hi_events') ? 1 : 3; } + + private function requiresApproval(): bool + { + return (bool) $this->config->get('app.require_account_approval', false); + } + + private function sendApprovalRequestToAdmin(UserDomainObject $user, AccountDomainObject $account): void + { + $adminEmail = $this->config->get('app.admin_email'); + if (!$adminEmail) { + $this->logger->error('APP_ADMIN_EMAIL not configured but account approval is required'); + return; + } + + $token = $this->encryptedPayloadService->encryptPayload([ + 'account_id' => $account->getId(), + ], Carbon::now()->addDays(30)); + + $approveUrl = config('app.frontend_url') . '/admin/approve-account?token=' . urlencode($token); + + $this->mailer + ->to($adminEmail) + ->send(new AccountApprovalRequestEmail($user, $account, $approveUrl)); + + $this->logger->info('Account approval request sent to admin', [ + 'account_id' => $account->getId(), + 'admin_email' => $adminEmail, + ]); + } } diff --git a/backend/app/Services/Application/Handlers/Account/EmailSetting/DTO/UpsertAccountEmailSettingDTO.php b/backend/app/Services/Application/Handlers/Account/EmailSetting/DTO/UpsertAccountEmailSettingDTO.php new file mode 100644 index 0000000000..d7d633cc5a --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/EmailSetting/DTO/UpsertAccountEmailSettingDTO.php @@ -0,0 +1,21 @@ +emailSettingRepository->findByAccountId($accountId); + } +} diff --git a/backend/app/Services/Application/Handlers/Account/EmailSetting/UpsertAccountEmailSettingHandler.php b/backend/app/Services/Application/Handlers/Account/EmailSetting/UpsertAccountEmailSettingHandler.php new file mode 100644 index 0000000000..32e58b2a84 --- /dev/null +++ b/backend/app/Services/Application/Handlers/Account/EmailSetting/UpsertAccountEmailSettingHandler.php @@ -0,0 +1,48 @@ +emailSettingRepository->findByAccountId($command->accountId); + + $data = [ + 'account_id' => $command->accountId, + 'smtp_enabled' => $command->smtpEnabled, + 'smtp_host' => $command->smtpHost, + 'smtp_port' => $command->smtpPort, + 'smtp_encryption' => $command->smtpEncryption, + 'smtp_username' => $command->smtpUsername, + 'mail_from_address' => $command->mailFromAddress, + 'mail_from_name' => $command->mailFromName, + ]; + + // Only overwrite the password if a new one is provided. + // An empty/null value means "leave existing password unchanged". + if ($command->smtpPassword !== null && $command->smtpPassword !== '') { + $data['smtp_password'] = $command->smtpPassword; + } + + if ($existing) { + return $this->emailSettingRepository->updateFromArray( + id: $existing->getId(), + attributes: $data, + ); + } + + return $this->emailSettingRepository->create($data); + } +} diff --git a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php index 7dceaaef96..5a30fa62fb 100644 --- a/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php +++ b/backend/app/Services/Domain/Attendee/SendAttendeeTicketService.php @@ -8,13 +8,15 @@ use HiEvents\DomainObjects\OrderDomainObject; use HiEvents\DomainObjects\OrganizerDomainObject; use HiEvents\Services\Domain\Email\MailBuilderService; +use HiEvents\Services\Domain\Mail\AccountMailerFactory; use Illuminate\Contracts\Mail\Mailer; class SendAttendeeTicketService { public function __construct( - private readonly Mailer $mailer, - private readonly MailBuilderService $mailBuilderService, + private readonly Mailer $mailer, + private readonly MailBuilderService $mailBuilderService, + private readonly AccountMailerFactory $accountMailerFactory, ) { } @@ -35,7 +37,9 @@ public function send( $organizer ); - $this->mailer + $mailer = $this->accountMailerFactory->forAccount($event->getAccountId()); + + $mailer ->to($attendee->getEmail()) ->locale($attendee->getLocale()) ->send($mail); diff --git a/backend/app/Services/Domain/Auth/LoginService.php b/backend/app/Services/Domain/Auth/LoginService.php index 0fb407acd1..c664efc620 100644 --- a/backend/app/Services/Domain/Auth/LoginService.php +++ b/backend/app/Services/Domain/Auth/LoginService.php @@ -53,6 +53,10 @@ public function authenticate(string $email, string $password, ?int $requestedAcc $accountId = $this->getAccountId($accounts, $requestedAccountId); + if ($accountId && config('app.require_account_approval', false)) { + $this->validateAccountApproval($accountId, $accounts); + } + if ($accountId) { $this->validateUserStatus($accountId, $userAccounts); } @@ -150,4 +154,16 @@ private function getUserRole(?int $accountId, Collection $userAccounts): ?Role return Role::from($currentAccount?->getRole()); } + + private function validateAccountApproval(int $accountId, Collection $accounts): void + { + /** @var \HiEvents\DomainObjects\AccountDomainObject|null $account */ + $account = $accounts->first(fn($a) => $a->getId() === $accountId); + + if ($account && $account->getApprovedAt() === null) { + throw new UnauthorizedException( + __('Your account is pending approval. You will receive an email once it has been approved.') + ); + } + } } diff --git a/backend/app/Services/Domain/Mail/AccountMailerFactory.php b/backend/app/Services/Domain/Mail/AccountMailerFactory.php new file mode 100644 index 0000000000..751841fc80 --- /dev/null +++ b/backend/app/Services/Domain/Mail/AccountMailerFactory.php @@ -0,0 +1,73 @@ +emailSettingRepository->findByAccountId($accountId); + + if ($setting && $this->isUsable($setting)) { + return $this->buildCustomMailer($setting); + } + + return $this->mailManager->mailer(); + } + + private function isUsable(AccountEmailSettingDomainObject $setting): bool + { + return $setting->getSmtpEnabled() + && $setting->getSmtpHost() !== null + && $setting->getSmtpPort() !== null; + } + + private function buildCustomMailer(AccountEmailSettingDomainObject $setting): Mailer + { + $mailerName = 'account_smtp_' . $setting->getAccountId(); + + // Register the mailer config at runtime so Laravel's MailManager can resolve it. + config([ + "mail.mailers.{$mailerName}" => [ + 'transport' => 'smtp', + 'host' => $setting->getSmtpHost(), + 'port' => $setting->getSmtpPort(), + 'encryption' => $setting->getSmtpEncryption(), + 'username' => $setting->getSmtpUsername(), + 'password' => $setting->getSmtpPassword(), + 'timeout' => null, + ], + ]); + + if ($setting->getMailFromAddress()) { + config([ + "mail.from" => [ + 'address' => $setting->getMailFromAddress(), + 'name' => $setting->getMailFromName() ?? config('mail.from.name'), + ], + ]); + } + + return $this->mailManager->mailer($mailerName); + } +} diff --git a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php index 833ac9b164..ae93b04f63 100644 --- a/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php +++ b/backend/app/Services/Domain/Mail/SendEventEmailMessagesService.php @@ -256,6 +256,7 @@ private function sendMessage( messageData: $messageData ), messageData: $messageData, + accountId: $event->getAccountId(), ) ); diff --git a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php index a1b139d650..f9d53e625d 100644 --- a/backend/app/Services/Domain/Mail/SendOrderDetailsService.php +++ b/backend/app/Services/Domain/Mail/SendOrderDetailsService.php @@ -17,14 +17,13 @@ use HiEvents\Repository\Interfaces\OrderRepositoryInterface; use HiEvents\Services\Domain\Attendee\SendAttendeeTicketService; use HiEvents\Services\Domain\Email\MailBuilderService; -use Illuminate\Mail\Mailer; class SendOrderDetailsService { public function __construct( private readonly EventRepositoryInterface $eventRepository, private readonly OrderRepositoryInterface $orderRepository, - private readonly Mailer $mailer, + private readonly AccountMailerFactory $accountMailerFactory, private readonly SendAttendeeTicketService $sendAttendeeTicketService, private readonly MailBuilderService $mailBuilderService, ) @@ -44,13 +43,15 @@ public function sendOrderSummaryAndTicketEmails(OrderDomainObject $order): void ->loadRelation(new Relationship(EventSettingDomainObject::class)) ->findById($order->getEventId()); + $mailer = $this->accountMailerFactory->forAccount($event->getAccountId()); + if ($order->isOrderCompleted() || $order->isOrderAwaitingOfflinePayment()) { - $this->sendOrderSummaryEmails($order, $event); + $this->sendOrderSummaryEmails($order, $event, $mailer); $this->sendAttendeeTicketEmails($order, $event); } if ($order->isOrderFailed()) { - $this->mailer + $mailer ->to($order->getEmail()) ->locale($order->getLocale()) ->send(new OrderFailed( @@ -70,6 +71,8 @@ public function sendCustomerOrderSummary( ?InvoiceDomainObject $invoice = null ): void { + $mailer = $this->accountMailerFactory->forAccount($event->getAccountId()); + $mail = $this->mailBuilderService->buildOrderSummaryMail( $order, $event, @@ -78,7 +81,7 @@ public function sendCustomerOrderSummary( $invoice ); - $this->mailer + $mailer ->to($order->getEmail()) ->locale($order->getLocale()) ->send($mail); @@ -104,7 +107,11 @@ private function sendAttendeeTicketEmails(OrderDomainObject $order, EventDomainO } } - private function sendOrderSummaryEmails(OrderDomainObject $order, EventDomainObject $event): void + private function sendOrderSummaryEmails( + OrderDomainObject $order, + EventDomainObject $event, + \Illuminate\Contracts\Mail\Mailer $mailer, + ): void { $this->sendCustomerOrderSummary( order: $order, @@ -118,7 +125,7 @@ private function sendOrderSummaryEmails(OrderDomainObject $order, EventDomainObj return; } - $this->mailer + $mailer ->to($event->getOrganizer()->getEmail()) ->send(new OrderSummaryForOrganizer($order, $event)); } diff --git a/backend/config/app.php b/backend/config/app.php index 50bcabc3e9..e83666f864 100644 --- a/backend/config/app.php +++ b/backend/config/app.php @@ -18,6 +18,8 @@ 'saas_stripe_application_fee_fixed' => env('APP_SAAS_STRIPE_APPLICATION_FEE_FIXED', 0), 'saas_default_pass_platform_fee_to_buyer' => env('APP_SAAS_DEFAULT_PASS_PLATFORM_FEE_TO_BUYER', false), 'disable_registration' => env('APP_DISABLE_REGISTRATION', false), + 'require_account_approval' => env('APP_REQUIRE_ACCOUNT_APPROVAL', false), + 'admin_email' => env('APP_ADMIN_EMAIL'), 'api_rate_limit_per_minute' => env('APP_API_RATE_LIMIT_PER_MINUTE', 180), 'stripe_connect_account_type' => env('APP_STRIPE_CONNECT_ACCOUNT_TYPE', 'express'), 'platform_support_email' => env('APP_PLATFORM_SUPPORT_EMAIL', 'support@example.com'), diff --git a/backend/database/migrations/2026_07_03_000001_create_account_email_settings_table.php b/backend/database/migrations/2026_07_03_000001_create_account_email_settings_table.php new file mode 100644 index 0000000000..ca288f5994 --- /dev/null +++ b/backend/database/migrations/2026_07_03_000001_create_account_email_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->foreignId('account_id')->constrained('accounts')->onDelete('cascade'); + $table->boolean('smtp_enabled')->default(false); + $table->string('smtp_host')->nullable(); + $table->unsignedSmallInteger('smtp_port')->nullable(); + $table->string('smtp_encryption')->nullable(); // tls, ssl, or null + $table->string('smtp_username')->nullable(); + $table->text('smtp_password')->nullable(); // encrypted at rest + $table->string('mail_from_address')->nullable(); + $table->string('mail_from_name')->nullable(); + $table->timestamps(); + $table->softDeletes(); + }); + } + + public function down(): void + { + Schema::dropIfExists('account_email_settings'); + } +}; diff --git a/backend/database/migrations/2026_07_06_000001_add_approved_at_to_accounts.php b/backend/database/migrations/2026_07_06_000001_add_approved_at_to_accounts.php new file mode 100644 index 0000000000..4949ac6d76 --- /dev/null +++ b/backend/database/migrations/2026_07_06_000001_add_approved_at_to_accounts.php @@ -0,0 +1,27 @@ +timestamp('approved_at')->nullable()->after('account_verified_at'); + }); + + // Backfill existing accounts as approved + DB::table('accounts')->whereNull('approved_at')->update([ + 'approved_at' => now(), + ]); + } + + public function down(): void + { + Schema::table('accounts', function (Blueprint $table) { + $table->dropColumn('approved_at'); + }); + } +}; diff --git a/backend/resources/views/emails/admin/account-approval-request.blade.php b/backend/resources/views/emails/admin/account-approval-request.blade.php new file mode 100644 index 0000000000..222dea9339 --- /dev/null +++ b/backend/resources/views/emails/admin/account-approval-request.blade.php @@ -0,0 +1,25 @@ +@php /** @var \HiEvents\DomainObjects\UserDomainObject $user */ @endphp +@php /** @var \HiEvents\DomainObjects\AccountDomainObject $account */ @endphp +@php /** @var string $approveUrl */ @endphp + + +# New Account Registration + +A new account registration is awaiting your approval. + +**Name:** {{ $user->getFirstName() }} {{ $user->getLastName() }} +**Email:** {{ $user->getEmail() }} +**Account:** {{ $account->getName() }} +**Registered:** {{ $account->getCreatedAt() }} + +Click the button below to approve this account. Once approved, the user will receive their email confirmation and be able to log in. + + + Approve Account + + +If you do not recognise this registration, simply ignore this email. The account will remain inactive. + +{{ __('Best Regards,') }} +{{ config('app.name') }} + diff --git a/backend/resources/views/vendor/mail/html/message.blade.php b/backend/resources/views/vendor/mail/html/message.blade.php index 737c8aa3e3..16484c99f8 100644 --- a/backend/resources/views/vendor/mail/html/message.blade.php +++ b/backend/resources/views/vendor/mail/html/message.blade.php @@ -1,13 +1,56 @@ {{-- Header --}} - - @if($appLogo = config('app.email_logo_url')) - +@php + $logoPath = null; + $organizerName = null; + $organizerId = null; + + // 1. Resolve organizer ID from active variables or fallback request contexts + if (isset($event) && $event->organizer) { + $organizerId = $event->organizer->id; + $organizerName = $event->organizer->name; + } elseif (isset($order) && $order->event && $order->event->organizer) { + $organizerId = $order->event->organizer->id; + $organizerName = $order->event->organizer->name; + } else { + $eventId = request()->route('event_id') ?? request()->input('event_id') ?? ($order->event_id ?? null); + if ($eventId) { + $dbEvent = \DB::table('events')->where('id', $eventId)->first(); + if ($dbEvent) { + $organizerId = $dbEvent->organizer_id; + } + } + } + + // 2. Fetch the true asset path from the custom images ledger if an organizer was resolved + if ($organizerId) { + if (!$organizerName) { + $dbOrg = \DB::table('organizers')->where('id', $organizerId)->first(); + $organizerName = $dbOrg->name ?? null; + } + + $dbImage = \DB::table('images') + ->where('entity_id', $organizerId) + ->where('entity_type', 'HiEvents\DomainObjects\OrganizerDomainObject') + ->first(); + + if ($dbImage) { + $logoPath = $dbImage->path; + } + } + @endphp + + + @if($logoPath) + {{-- Render the true dynamic tenant organizer logo asset link --}} + + @elseif($appLogo = config('app.email_logo_url')) + {{-- Fallback to global setting --}} + @else - + {{-- Clean fallback typography --}} + {{ $organizerName ?? env('VITE_APP_NAME', 'Hi.Events') }} @endif diff --git a/backend/routes/api.php b/backend/routes/api.php index e3947d0804..725d5e5758 100644 --- a/backend/routes/api.php +++ b/backend/routes/api.php @@ -1,6 +1,10 @@ get('/accounts/approve', ApproveAccountAction::class)->name('accounts.approve'); +$router->post('/accounts/resend-approval', ResendApprovalRequestAction::class)->middleware('throttle:3,10')->name('accounts.resend-approval'); + /** * Logged In Routes */ @@ -272,6 +280,10 @@ function (Router $router): void { $router->get('/accounts/{account_id}/vat-settings', GetAccountVatSettingAction::class); $router->post('/accounts/{account_id}/vat-settings', UpsertAccountVatSettingAction::class); + // Email Settings + $router->get('/accounts/{account_id}/email-settings', GetAccountEmailSettingAction::class); + $router->post('/accounts/{account_id}/email-settings', UpsertAccountEmailSettingAction::class); + // Organizers $router->post('/organizers', CreateOrganizerAction::class); // This is POST instead of PUT because you can't upload files via PUT in PHP (at least not easily) diff --git a/docker/all-in-one/.env.example b/docker/all-in-one/.env.example index 3a7b4f6130..c819290b20 100644 --- a/docker/all-in-one/.env.example +++ b/docker/all-in-one/.env.example @@ -20,6 +20,8 @@ QUEUE_CONNECTION=redis APP_CDN_URL=http://localhost:8123/storage APP_FRONTEND_URL=http://localhost:8123 APP_DISABLE_REGISTRATION=false +APP_REQUIRE_ACCOUNT_APPROVAL=false +APP_ADMIN_EMAIL= APP_SAAS_MODE_ENABLED=false APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=0 APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=0 diff --git a/docker/all-in-one/docker-compose.yml b/docker/all-in-one/docker-compose.yml index 28f912c617..4bafae2b3b 100644 --- a/docker/all-in-one/docker-compose.yml +++ b/docker/all-in-one/docker-compose.yml @@ -19,6 +19,8 @@ services: - APP_FRONTEND_URL=${APP_FRONTEND_URL} - JWT_SECRET=${JWT_SECRET} - APP_DISABLE_REGISTRATION=${APP_DISABLE_REGISTRATION} + - APP_REQUIRE_ACCOUNT_APPROVAL=${APP_REQUIRE_ACCOUNT_APPROVAL:-false} + - APP_ADMIN_EMAIL=${APP_ADMIN_EMAIL:-} - APP_SAAS_MODE_ENABLED=${APP_SAAS_MODE_ENABLED} - APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT=${APP_SAAS_STRIPE_APPLICATION_FEE_PERCENT} - APP_SAAS_STRIPE_APPLICATION_FEE_FIXED=${APP_SAAS_STRIPE_APPLICATION_FEE_FIXED} @@ -43,7 +45,8 @@ services: - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY} - STRIPE_WEBHOOK_SECRET=${STRIPE_WEBHOOK_SECRET} - WEBHOOK_QUEUE_NAME=webhook-queue - + volumes: + - appdata:/app/backend/storage depends_on: postgres: condition: service_healthy @@ -79,3 +82,4 @@ services: volumes: pgdata: redisdata: + appdata: diff --git a/frontend/src/api/account.client.ts b/frontend/src/api/account.client.ts index 848c398612..4fd5cef23e 100644 --- a/frontend/src/api/account.client.ts +++ b/frontend/src/api/account.client.ts @@ -1,5 +1,5 @@ import {api} from "./client.ts"; -import {Account, GenericDataResponse, IdParam, User, StripeConnectAccountsResponse} from "../types.ts"; +import {Account, AccountEmailSettings, GenericDataResponse, IdParam, User, StripeConnectAccountsResponse} from "../types.ts"; interface CreateAccountRequest { first_name: string; @@ -30,5 +30,13 @@ export const accountClient = { getStripeConnectAccounts: async (accountId: IdParam) => { const response = await api.get>(`accounts/${accountId}/stripe/connect_accounts`); return response.data; - } + }, + getEmailSettings: async (accountId: IdParam) => { + const response = await api.get>(`accounts/${accountId}/email-settings`); + return response.data; + }, + updateEmailSettings: async (accountId: IdParam, settings: AccountEmailSettings) => { + const response = await api.post>(`accounts/${accountId}/email-settings`, settings); + return response.data; + }, } \ No newline at end of file diff --git a/frontend/src/components/routes/account/ManageAccount/index.tsx b/frontend/src/components/routes/account/ManageAccount/index.tsx index 67602ddcbe..71ef78c409 100644 --- a/frontend/src/components/routes/account/ManageAccount/index.tsx +++ b/frontend/src/components/routes/account/ManageAccount/index.tsx @@ -1,7 +1,7 @@ import {Card} from "../../../common/Card"; import {Tabs} from "@mantine/core"; import classes from "./ManageAccount.module.scss"; -import {IconAdjustmentsCog, IconCreditCard, IconReceiptTax, IconUsers} from "@tabler/icons-react"; +import {IconAdjustmentsCog, IconCreditCard, IconMail, IconReceiptTax, IconUsers} from "@tabler/icons-react"; import {Outlet, useLocation, useNavigate} from "react-router"; import {t} from "@lingui/macro"; import {useIsCurrentUserAdmin} from "../../../../hooks/useIsCurrentUserAdmin.ts"; @@ -27,6 +27,12 @@ export const ManageAccount = () => { {t`Tax & Fees`} + {isUserAdmin && ( + }> + {t`Email`} + + )} + {isUserAdmin && ( }> {t`Users`} diff --git a/frontend/src/components/routes/account/ManageAccount/sections/EmailSettings/index.tsx b/frontend/src/components/routes/account/ManageAccount/sections/EmailSettings/index.tsx new file mode 100644 index 0000000000..633a636575 --- /dev/null +++ b/frontend/src/components/routes/account/ManageAccount/sections/EmailSettings/index.tsx @@ -0,0 +1,185 @@ +import {Button, Checkbox, Select, TextInput, PasswordInput, Divider, Text} from "@mantine/core"; +import {useForm} from "@mantine/form"; +import {useEffect} from "react"; +import {t} from "@lingui/macro"; +import {Card} from "../../../../../common/Card"; +import {HeadingCard} from "../../../../../common/HeadingCard"; +import {LoadingMask} from "../../../../../common/LoadingMask"; +import {useGetAccount} from "../../../../../../queries/useGetAccount.ts"; +import {useGetAccountEmailSettings} from "../../../../../../queries/useGetAccountEmailSettings.ts"; +import {useUpsertAccountEmailSettings} from "../../../../../../mutations/useUpsertAccountEmailSettings.ts"; +import {showSuccess} from "../../../../../../utilites/notifications.tsx"; +import {useFormErrorResponseHandler} from "../../../../../../hooks/useFormErrorResponseHandler.tsx"; +import {AccountEmailSettings} from "../../../../../../types.ts"; +import classes from "../../ManageAccount.module.scss"; + +const ENCRYPTION_OPTIONS = [ + {value: '', label: 'None'}, + {value: 'tls', label: 'TLS'}, + {value: 'ssl', label: 'SSL'}, + {value: 'starttls', label: 'STARTTLS'}, +]; + +const EmailSettings = () => { + const {data: account} = useGetAccount(); + const accountId = account?.id; + + const emailSettingsQuery = useGetAccountEmailSettings(accountId, { + enabled: !!accountId, + }); + + const upsertMutation = useUpsertAccountEmailSettings(accountId); + const formErrorHandler = useFormErrorResponseHandler(); + + const form = useForm({ + initialValues: { + smtp_enabled: false, + smtp_host: '', + smtp_port: undefined, + smtp_encryption: '', + smtp_username: '', + smtp_password: '', + mail_from_address: '', + mail_from_name: '', + }, + }); + + useEffect(() => { + if (emailSettingsQuery.data) { + const s = emailSettingsQuery.data; + form.setValues({ + smtp_enabled: s.smtp_enabled ?? false, + smtp_host: s.smtp_host ?? '', + smtp_port: s.smtp_port, + smtp_encryption: s.smtp_encryption ?? '', + smtp_username: s.smtp_username ?? '', + // Never prefill the password – backend keeps the existing one if blank + smtp_password: '', + mail_from_address: s.mail_from_address ?? '', + mail_from_name: s.mail_from_name ?? '', + }); + } + }, [emailSettingsQuery.isFetched]); + + const handleSubmit = (values: AccountEmailSettings) => { + upsertMutation.mutate(values, { + onSuccess: () => { + showSuccess(t`Email settings saved`); + emailSettingsQuery.refetch(); + }, + onError: (error) => { + formErrorHandler(form, error); + }, + }); + }; + + const isLoading = upsertMutation.isPending || emailSettingsQuery.isLoading; + const smtpEnabled = form.values.smtp_enabled; + const passwordIsSet = emailSettingsQuery.data?.smtp_password_set; + + return ( + <> + + + + + + + + {smtpEnabled && ( + <> + + + + + + form.setFieldValue( + 'smtp_port', + e.currentTarget.value ? parseInt(e.currentTarget.value, 10) : undefined + ) + } + /> + + + + + + + + + + {t`From Address`} + + + + + > + )} + + + + {t`Save Settings`} + + + + + + > + ); +}; + +export default EmailSettings; diff --git a/frontend/src/components/routes/admin/ApproveAccount.tsx b/frontend/src/components/routes/admin/ApproveAccount.tsx new file mode 100644 index 0000000000..20a90c56ca --- /dev/null +++ b/frontend/src/components/routes/admin/ApproveAccount.tsx @@ -0,0 +1,63 @@ +import {useEffect, useState} from "react"; +import {useSearchParams} from "react-router"; +import {api} from "../../../api/client.ts"; +import {Card} from "../../common/Card"; +import {t} from "@lingui/macro"; + +const ApproveAccount = () => { + const [searchParams] = useSearchParams(); + const token = searchParams.get('token'); + const [status, setStatus] = useState<'loading' | 'success' | 'error' | 'already'>('loading'); + const [message, setMessage] = useState(''); + + useEffect(() => { + if (!token) { + setStatus('error'); + setMessage(t`Missing approval token.`); + return; + } + + api.get(`/accounts/approve`, { params: { token } }) + .then((res) => { + const msg = res.data?.message || t`Account approved successfully.`; + if (msg.includes('already')) { + setStatus('already'); + } else { + setStatus('success'); + } + setMessage(msg); + }) + .catch((err) => { + setStatus('error'); + setMessage(err.response?.data?.message || t`Failed to approve account.`); + }); + }, [token]); + + return ( + + + {status === 'loading' && {t`Approving account...`}} + {status === 'success' && ( + <> + ✓ {t`Account Approved`} + {message} + > + )} + {status === 'already' && ( + <> + ℹ️ {t`Already Approved`} + {message} + > + )} + {status === 'error' && ( + <> + ✗ {t`Approval Failed`} + {message} + > + )} + + + ); +}; + +export default ApproveAccount; diff --git a/frontend/src/components/routes/auth/Login/index.tsx b/frontend/src/components/routes/auth/Login/index.tsx index 43a25f1306..b67c3ff1e6 100644 --- a/frontend/src/components/routes/auth/Login/index.tsx +++ b/frontend/src/components/routes/auth/Login/index.tsx @@ -11,8 +11,9 @@ import {t, Trans} from "@lingui/macro"; import {useEffect, useState} from "react"; import {ChooseAccountModal} from "../../../modals/ChooseAccountModal"; import {useSendTicketLookupEmail} from "../../../../mutations/useSendTicketLookupEmail.ts"; -import {showError} from "../../../../utilites/notifications.tsx"; +import {showError, showSuccess} from "../../../../utilites/notifications.tsx"; import {IconTicket, IconChevronDown} from "@tabler/icons-react"; +import {api} from "../../../../api/client.ts"; const Login = () => { const location = useLocation(); @@ -24,6 +25,8 @@ const Login = () => { } }); const [showChooseAccount, setShowChooseAccount] = useState(false); + const [pendingApproval, setPendingApproval] = useState(false); + const [resendingApproval, setResendingApproval] = useState(false); const [ticketLookupOpen, setTicketLookupOpen] = useState(false); const ticketLookupForm = useForm({ @@ -37,6 +40,7 @@ const Login = () => { mutationFn: (userData: LoginData) => authClient.login(userData), onSuccess: (response: LoginResponse) => { + setPendingApproval(false); if (response.token) { redirectToPreviousUrl(); return; @@ -48,15 +52,36 @@ const Login = () => { } }, - onError: () => { - notifications.show({ - message: t`Please check your email and password and try again`, - color: 'red', - position: 'top-center', - }); + onError: (error: any) => { + const message = error?.response?.data?.message + || t`Please check your email and password and try again`; + + // Check if this is a pending approval error + if (message.toLowerCase().includes('pending approval') || message.toLowerCase().includes('awaiting approval')) { + setPendingApproval(true); + } else { + setPendingApproval(false); + notifications.show({ + message, + color: 'red', + position: 'top-center', + }); + } } }); + const handleResendApproval = async () => { + setResendingApproval(true); + try { + await api.post('/accounts/resend-approval', { email: form.values.email }); + showSuccess(t`A new approval request has been sent to the administrator.`); + } catch { + showError(t`Something went wrong. Please try again.`); + } finally { + setResendingApproval(false); + } + }; + const ticketLookupMutation = useSendTicketLookupEmail(); useEffect(() => { @@ -100,9 +125,39 @@ const Login = () => { required mt="md" /> - + {isPending ? t`Logging in` : t`Log in`} + + {pendingApproval && ( + + + {t`Your account is pending admin approval.`} + + + {t`You will receive an email once your account has been approved.`} + + + {t`Resend Approval Request`} + + + )} + {t`Forgot password?`} diff --git a/frontend/src/components/routes/auth/PendingApproval/index.tsx b/frontend/src/components/routes/auth/PendingApproval/index.tsx new file mode 100644 index 0000000000..0ea7fb9f85 --- /dev/null +++ b/frontend/src/components/routes/auth/PendingApproval/index.tsx @@ -0,0 +1,26 @@ +import {Card} from "../../../common/Card"; +import {t} from "@lingui/macro"; +import {IconClock} from "@tabler/icons-react"; +import {Button, Center, Stack, Text} from "@mantine/core"; +import {NavLink} from "react-router"; + +const PendingApproval = () => { + return ( + + + + + {t`Account Pending Approval`} + + {t`Your account has been created and is awaiting admin approval. You will receive an email notification once your account has been approved and you can log in.`} + + + {t`Back to Login`} + + + + + ); +}; + +export default PendingApproval; diff --git a/frontend/src/components/routes/auth/Register/index.tsx b/frontend/src/components/routes/auth/Register/index.tsx index 1064226da7..d7cac56f76 100644 --- a/frontend/src/components/routes/auth/Register/index.tsx +++ b/frontend/src/components/routes/auth/Register/index.tsx @@ -46,9 +46,13 @@ export const Register = () => { const registrationData = utmData ? {...data, ...utmData} : data; mutate.mutate({registerData: registrationData}, { - onSuccess: () => { + onSuccess: (response: any) => { clearStoredUtmData(); - navigate(`/welcome${location.search}`); + if (response?.pending_approval) { + navigate('/auth/pending-approval'); + } else { + navigate(`/welcome${location.search}`); + } }, onError: (error: any) => { errorHandler(form, error, error.response?.data?.message); diff --git a/frontend/src/mutations/useUpsertAccountEmailSettings.ts b/frontend/src/mutations/useUpsertAccountEmailSettings.ts new file mode 100644 index 0000000000..0e00527571 --- /dev/null +++ b/frontend/src/mutations/useUpsertAccountEmailSettings.ts @@ -0,0 +1,19 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {AccountEmailSettings, IdParam} from '../types.ts'; +import {accountClient} from '../api/account.client.ts'; +import {GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY} from '../queries/useGetAccountEmailSettings.ts'; + +export const useUpsertAccountEmailSettings = (accountId: IdParam) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: AccountEmailSettings) => { + return await accountClient.updateEmailSettings(accountId, data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY, accountId], + }); + }, + }); +}; diff --git a/frontend/src/queries/useGetAccountEmailSettings.ts b/frontend/src/queries/useGetAccountEmailSettings.ts new file mode 100644 index 0000000000..816ce1b6d0 --- /dev/null +++ b/frontend/src/queries/useGetAccountEmailSettings.ts @@ -0,0 +1,19 @@ +import {useQuery, UseQueryOptions} from '@tanstack/react-query'; +import {AccountEmailSettings, IdParam} from '../types.ts'; +import {accountClient} from '../api/account.client.ts'; + +export const GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY = 'accountEmailSettings'; + +export const useGetAccountEmailSettings = ( + accountId: IdParam, + options?: Partial> +) => { + return useQuery({ + queryKey: [GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY, accountId], + queryFn: async () => { + const {data} = await accountClient.getEmailSettings(accountId); + return data; + }, + ...options, + }); +}; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 5eaa8aa13b..52263084e6 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -252,6 +252,13 @@ export const router: RouteObject[] = [ return { Component: EventDefaultsSettings.default }; } }, + { + path: "email-settings", + async lazy() { + const EmailSettings = await import("./components/routes/account/ManageAccount/sections/EmailSettings"); + return { Component: EmailSettings.default }; + } + }, { path: "users", async lazy() { @@ -622,6 +629,20 @@ export const router: RouteObject[] = [ return { Component: MyTickets.default }; }, errorElement: , - } + }, + { + path: "/admin/approve-account", + async lazy() { + const ApproveAccount = await import("./components/routes/admin/ApproveAccount"); + return { Component: ApproveAccount.default }; + }, + }, + { + path: "/auth/pending-approval", + async lazy() { + const PendingApproval = await import("./components/routes/auth/PendingApproval"); + return { Component: PendingApproval.default }; + }, + }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 039bc3e615..8357feeead 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -137,6 +137,20 @@ export interface AccountConfiguration { is_system_default: boolean; } +export interface AccountEmailSettings { + id?: IdParam; + account_id?: IdParam; + smtp_enabled: boolean; + smtp_host?: string; + smtp_port?: number; + smtp_encryption?: 'tls' | 'ssl' | 'starttls' | ''; + smtp_username?: string; + smtp_password?: string; + smtp_password_set?: boolean; + mail_from_address?: string; + mail_from_name?: string; +} + export interface StripeConnectDetails { account: Account; stripe_account_id: string;
{t`Approving account...`}
{message}
+ {t`Your account is pending admin approval.`} +
+ {t`You will receive an email once your account has been approved.`} +
{t`Forgot password?`} diff --git a/frontend/src/components/routes/auth/PendingApproval/index.tsx b/frontend/src/components/routes/auth/PendingApproval/index.tsx new file mode 100644 index 0000000000..0ea7fb9f85 --- /dev/null +++ b/frontend/src/components/routes/auth/PendingApproval/index.tsx @@ -0,0 +1,26 @@ +import {Card} from "../../../common/Card"; +import {t} from "@lingui/macro"; +import {IconClock} from "@tabler/icons-react"; +import {Button, Center, Stack, Text} from "@mantine/core"; +import {NavLink} from "react-router"; + +const PendingApproval = () => { + return ( + + + + + {t`Account Pending Approval`} + + {t`Your account has been created and is awaiting admin approval. You will receive an email notification once your account has been approved and you can log in.`} + + + {t`Back to Login`} + + + + + ); +}; + +export default PendingApproval; diff --git a/frontend/src/components/routes/auth/Register/index.tsx b/frontend/src/components/routes/auth/Register/index.tsx index 1064226da7..d7cac56f76 100644 --- a/frontend/src/components/routes/auth/Register/index.tsx +++ b/frontend/src/components/routes/auth/Register/index.tsx @@ -46,9 +46,13 @@ export const Register = () => { const registrationData = utmData ? {...data, ...utmData} : data; mutate.mutate({registerData: registrationData}, { - onSuccess: () => { + onSuccess: (response: any) => { clearStoredUtmData(); - navigate(`/welcome${location.search}`); + if (response?.pending_approval) { + navigate('/auth/pending-approval'); + } else { + navigate(`/welcome${location.search}`); + } }, onError: (error: any) => { errorHandler(form, error, error.response?.data?.message); diff --git a/frontend/src/mutations/useUpsertAccountEmailSettings.ts b/frontend/src/mutations/useUpsertAccountEmailSettings.ts new file mode 100644 index 0000000000..0e00527571 --- /dev/null +++ b/frontend/src/mutations/useUpsertAccountEmailSettings.ts @@ -0,0 +1,19 @@ +import {useMutation, useQueryClient} from '@tanstack/react-query'; +import {AccountEmailSettings, IdParam} from '../types.ts'; +import {accountClient} from '../api/account.client.ts'; +import {GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY} from '../queries/useGetAccountEmailSettings.ts'; + +export const useUpsertAccountEmailSettings = (accountId: IdParam) => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (data: AccountEmailSettings) => { + return await accountClient.updateEmailSettings(accountId, data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: [GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY, accountId], + }); + }, + }); +}; diff --git a/frontend/src/queries/useGetAccountEmailSettings.ts b/frontend/src/queries/useGetAccountEmailSettings.ts new file mode 100644 index 0000000000..816ce1b6d0 --- /dev/null +++ b/frontend/src/queries/useGetAccountEmailSettings.ts @@ -0,0 +1,19 @@ +import {useQuery, UseQueryOptions} from '@tanstack/react-query'; +import {AccountEmailSettings, IdParam} from '../types.ts'; +import {accountClient} from '../api/account.client.ts'; + +export const GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY = 'accountEmailSettings'; + +export const useGetAccountEmailSettings = ( + accountId: IdParam, + options?: Partial> +) => { + return useQuery({ + queryKey: [GET_ACCOUNT_EMAIL_SETTINGS_QUERY_KEY, accountId], + queryFn: async () => { + const {data} = await accountClient.getEmailSettings(accountId); + return data; + }, + ...options, + }); +}; diff --git a/frontend/src/router.tsx b/frontend/src/router.tsx index 5eaa8aa13b..52263084e6 100644 --- a/frontend/src/router.tsx +++ b/frontend/src/router.tsx @@ -252,6 +252,13 @@ export const router: RouteObject[] = [ return { Component: EventDefaultsSettings.default }; } }, + { + path: "email-settings", + async lazy() { + const EmailSettings = await import("./components/routes/account/ManageAccount/sections/EmailSettings"); + return { Component: EmailSettings.default }; + } + }, { path: "users", async lazy() { @@ -622,6 +629,20 @@ export const router: RouteObject[] = [ return { Component: MyTickets.default }; }, errorElement: , - } + }, + { + path: "/admin/approve-account", + async lazy() { + const ApproveAccount = await import("./components/routes/admin/ApproveAccount"); + return { Component: ApproveAccount.default }; + }, + }, + { + path: "/auth/pending-approval", + async lazy() { + const PendingApproval = await import("./components/routes/auth/PendingApproval"); + return { Component: PendingApproval.default }; + }, + }, ]; diff --git a/frontend/src/types.ts b/frontend/src/types.ts index 039bc3e615..8357feeead 100644 --- a/frontend/src/types.ts +++ b/frontend/src/types.ts @@ -137,6 +137,20 @@ export interface AccountConfiguration { is_system_default: boolean; } +export interface AccountEmailSettings { + id?: IdParam; + account_id?: IdParam; + smtp_enabled: boolean; + smtp_host?: string; + smtp_port?: number; + smtp_encryption?: 'tls' | 'ssl' | 'starttls' | ''; + smtp_username?: string; + smtp_password?: string; + smtp_password_set?: boolean; + mail_from_address?: string; + mail_from_name?: string; +} + export interface StripeConnectDetails { account: Account; stripe_account_id: string;