diff --git a/app/Enums/Server/ServerType.php b/app/Enums/Server/ServerType.php
new file mode 100644
index 0000000..4e1448b
--- /dev/null
+++ b/app/Enums/Server/ServerType.php
@@ -0,0 +1,19 @@
+ 'Domain / DNS',
+ self::HOSTING => 'Hosting',
+ self::BOTH => 'Domain & Hosting',
+ };
+ }
+}
diff --git a/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php
index f12e37d..5e03fcd 100644
--- a/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php
+++ b/app/Http/Controllers/Api/Admin/DnsManager/ServerController.php
@@ -74,6 +74,7 @@ class ServerController extends Controller
'password' => 'required|string|max:255',
'api_key' => 'nullable|string',
'status' => 'nullable|string|in:activated,Stopped',
+ 'type' => 'nullable|string|in:domain,hosting,both',
]);
DB::beginTransaction();
@@ -86,6 +87,7 @@ class ServerController extends Controller
'password' => Crypt::encryptString($validated['password']),
'api_key' => $validated['api_key'] ?? null,
'status' => $validated['status'] ?? 'activated',
+ 'type' => $validated['type'] ?? 'domain',
]);
DB::commit();
@@ -116,6 +118,7 @@ class ServerController extends Controller
'password' => 'nullable|string|max:255',
'api_key' => 'nullable|string',
'status' => 'nullable|string|in:activated,Stopped',
+ 'type' => 'nullable|string|in:domain,hosting,both',
]);
DB::beginTransaction();
@@ -182,6 +185,7 @@ class ServerController extends Controller
'username' => $server->username,
'api_key' => $server->api_key,
'status' => $server->status,
+ 'type' => $server->type,
'created_at' => $server->created_at,
'updated_at' => $server->updated_at,
];
diff --git a/app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php b/app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php
new file mode 100644
index 0000000..6de1aed
--- /dev/null
+++ b/app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php
@@ -0,0 +1,146 @@
+get('per_page', 15), 500);
+ $filter = $request->get('filter');
+ $status = $request->get('status');
+ $customerId = $request->get('customer_id');
+
+ $query = $this->accounts->model
+ ->with(self::RELATIONSHIPS)
+ ->newQuery();
+
+ if ($filter) {
+ $query->where(function ($sub) use ($filter) {
+ $sub->where('hosting_plan', 'like', "%{$filter}%")
+ ->orWhere('ftp_username', 'like', "%{$filter}%")
+ ->orWhere('system_username', 'like', "%{$filter}%");
+ });
+ }
+
+ if ($status !== null && $status !== '') {
+ $query->where('status', $status);
+ }
+
+ if ($customerId) {
+ $query->where('customer_id', $customerId);
+ }
+
+ $paginator = $query->orderByDesc('id')->paginate($perPage);
+
+ return response()->json([
+ 'error' => false,
+ 'data' => [
+ 'items' => HostingAccountResource::collection($paginator->getCollection()),
+ 'total' => $paginator->total(),
+ 'per_page' => $paginator->perPage(),
+ 'current_page' => $paginator->currentPage(),
+ 'last_page' => $paginator->lastPage(),
+ ],
+ ]);
+ }
+
+ public function show(int $id): JsonResponse
+ {
+ $account = $this->accounts->model->with(self::RELATIONSHIPS)->find($id);
+
+ if (!$account) {
+ return response()->json(['error' => true, 'message' => "Không tìm thấy hosting account #$id"], 404);
+ }
+
+ return response()->json(['error' => false, 'data' => new HostingAccountResource($account)]);
+ }
+
+ public function store(CreateHostingAccountRequest $request): JsonResponse
+ {
+ $result = $this->pleskHostingService->provision($request->validated());
+
+ if ($result['error']) {
+ return response()->json([
+ 'error' => true,
+ 'message' => $result['message'] ?? 'Không thể tạo hosting account trên Plesk',
+ ], 422);
+ }
+
+ $account = $result['data']->load(self::RELATIONSHIPS);
+
+ return response()->json([
+ 'error' => false,
+ 'data' => new HostingAccountResource($account),
+ // Not persisted anywhere — this response is the only place it's ever shown.
+ 'ftp_password' => $result['ftp_password'],
+ ], 201);
+ }
+
+ public function update(int $id, UpdateHostingAccountRequest $request): JsonResponse
+ {
+ $account = $this->accounts->find($id);
+
+ if (!$account) {
+ return response()->json(['error' => true, 'message' => "Không tìm thấy hosting account #$id"], 404);
+ }
+
+ $this->accounts->update($id, $request->validated());
+
+ return $this->show($id);
+ }
+
+ public function destroy(int $id): JsonResponse
+ {
+ $account = $this->accounts->find($id);
+
+ if (!$account) {
+ return response()->json(['error' => true, 'message' => "Không tìm thấy hosting account #$id"], 404);
+ }
+
+ // Local record only — does not deprovision the subscription on Plesk,
+ // same as DnsManager\DomainController::destroy leaving the Plesk side alone.
+ $this->accounts->delete($id);
+
+ return response()->json(['error' => false, 'message' => 'Đã xoá hosting account']);
+ }
+
+ public function suspend(int $id): JsonResponse
+ {
+ return $this->notImplemented();
+ }
+
+ public function unsuspend(int $id): JsonResponse
+ {
+ return $this->notImplemented();
+ }
+
+ public function resetFtpPassword(int $id): JsonResponse
+ {
+ return $this->notImplemented();
+ }
+
+ private function notImplemented(): JsonResponse
+ {
+ return response()->json([
+ 'error' => true,
+ 'message' => 'Not implemented in this phase',
+ ], 501);
+ }
+}
diff --git a/app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php b/app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php
new file mode 100644
index 0000000..b90e0ab
--- /dev/null
+++ b/app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php
@@ -0,0 +1,34 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'customer_id' => 'required|integer|exists:customers,id',
+ 'dns_manager_server_id' => 'required|integer|exists:dns_manager_servers,id',
+ 'dns_manager_domain_id' => 'nullable|integer|exists:dns_manager_domain,id',
+ 'service_id' => 'nullable|integer|exists:services,id',
+ 'domain_name' => 'required|string|max:255',
+ 'hosting_plan' => 'required|string|max:100',
+ 'ftp_password' => 'nullable|string|min:8|max:100',
+ ];
+ }
+}
diff --git a/app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php b/app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php
new file mode 100644
index 0000000..1028d84
--- /dev/null
+++ b/app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php
@@ -0,0 +1,34 @@
+|string>
+ */
+ public function rules(): array
+ {
+ return [
+ 'dns_manager_domain_id' => 'nullable|integer|exists:dns_manager_domain,id',
+ 'service_id' => 'nullable|integer|exists:services,id',
+ 'hosting_plan' => 'sometimes|required|string|max:100',
+ ];
+ }
+}
diff --git a/app/Http/Resources/Api/Admin/HostingAccountResource.php b/app/Http/Resources/Api/Admin/HostingAccountResource.php
new file mode 100644
index 0000000..85a6376
--- /dev/null
+++ b/app/Http/Resources/Api/Admin/HostingAccountResource.php
@@ -0,0 +1,33 @@
+ $this->id,
+ 'customer_id' => $this->customer_id,
+ 'customer_name' => $this->whenLoaded('customer', fn () => $this->customer?->fullname),
+ 'service_id' => $this->service_id,
+ 'dns_manager_server_id' => $this->dns_manager_server_id,
+ 'server_name' => $this->whenLoaded('server', fn () => $this->server?->name),
+ 'dns_manager_domain_id' => $this->dns_manager_domain_id,
+ 'domain_name' => $this->whenLoaded('domain', fn () => $this->domain?->domain_name),
+ 'plesk_client_id' => $this->plesk_client_id,
+ 'plesk_subscription_id' => $this->plesk_subscription_id,
+ 'plesk_guid' => $this->plesk_guid,
+ 'hosting_plan' => $this->hosting_plan,
+ 'ftp_username' => $this->ftp_username,
+ 'system_username' => $this->system_username,
+ 'document_root' => $this->document_root,
+ 'status' => $this->status,
+ 'created_at' => $this->created_at,
+ 'updated_at' => $this->updated_at,
+ ];
+ }
+}
diff --git a/app/Jobs/Mail/Admin/HostingAccountCreatedAdminNotificationJob.php b/app/Jobs/Mail/Admin/HostingAccountCreatedAdminNotificationJob.php
new file mode 100644
index 0000000..4ba61a9
--- /dev/null
+++ b/app/Jobs/Mail/Admin/HostingAccountCreatedAdminNotificationJob.php
@@ -0,0 +1,59 @@
+first();
+ $settingValue = $setting ? json_decode((string) $setting->value, true) : [];
+
+ $adminEmail = $settingValue['admin_email'] ?? null;
+
+ if (empty($adminEmail)) {
+ Log::warning('HostingAccountCreatedAdminNotificationJob: admin_email not configured in settings.');
+ return;
+ }
+
+ $payload = array_merge($this->data, [
+ 'company_name' => $settingValue['company'] ?? 'Portal Admin',
+ ]);
+
+ try {
+ Mail::to($adminEmail)->send(new HostingAccountCreatedAdminNotification($payload));
+ } catch (\Throwable $e) {
+ Log::error('HostingAccountCreatedAdminNotificationJob: failed to send email. ' . $e->getMessage(), [
+ 'admin_email' => $adminEmail,
+ 'domain_name' => $this->data['domain_name'] ?? null,
+ ]);
+ }
+ }
+
+ public function failed(\Throwable $e): void
+ {
+ Log::error('HostingAccountCreatedAdminNotificationJob: job failed.', [
+ 'error' => $e->getMessage(),
+ 'domain_name' => $this->data['domain_name'] ?? null,
+ ]);
+ }
+}
diff --git a/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php b/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php
new file mode 100644
index 0000000..bdf0278
--- /dev/null
+++ b/app/Jobs/Mail/Customer/HostingAccountCreatedJob.php
@@ -0,0 +1,54 @@
+data = $data;
+ }
+
+ public function handle(): void
+ {
+ if (empty($this->data['customer_email'])) {
+ Log::warning('HostingAccountCreatedJob: missing customer_email, skipped.', [
+ 'domain_name' => $this->data['domain_name'] ?? null,
+ ]);
+ return;
+ }
+
+ try {
+ Mail::to($this->data['customer_email'])
+ ->send(new HostingAccountCreated($this->data));
+ } catch (\Throwable $e) {
+ Log::error('Không thể gửi email thông báo khởi tạo hosting: ' . $e->getMessage(), [
+ 'customer_email' => $this->data['customer_email'] ?? null,
+ 'domain_name' => $this->data['domain_name'] ?? null,
+ ]);
+ }
+ }
+
+ public function failed(\Throwable $e): void
+ {
+ Log::error('HostingAccountCreatedJob: job failed.', [
+ 'error' => $e->getMessage(),
+ 'domain_name' => $this->data['domain_name'] ?? null,
+ ]);
+ }
+}
diff --git a/app/Mail/Admin/HostingAccountCreatedAdminNotification.php b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php
new file mode 100644
index 0000000..394c56c
--- /dev/null
+++ b/app/Mail/Admin/HostingAccountCreatedAdminNotification.php
@@ -0,0 +1,38 @@
+data['domain_name'] ?? 'N/A'),
+ );
+ }
+
+ public function content(): Content
+ {
+ return new Content(
+ view: 'emails.admin.hosting-account-created',
+ with: $this->data,
+ );
+ }
+
+ public function attachments(): array
+ {
+ return [];
+ }
+}
diff --git a/app/Mail/Customer/HostingAccountCreated.php b/app/Mail/Customer/HostingAccountCreated.php
new file mode 100644
index 0000000..0e38afa
--- /dev/null
+++ b/app/Mail/Customer/HostingAccountCreated.php
@@ -0,0 +1,72 @@
+payload()['company_name'] ?? config('app.name', 'The Gioi So');
+
+ return new Envelope(
+ subject: 'Hosting của bạn đã sẵn sàng — ' . ($this->data['domain_name'] ?? '') . ' — ' . $appName,
+ );
+ }
+
+ public function content(): Content
+ {
+ $data = $this->payload();
+
+ return new Content(
+ view: 'emails.customer.hosting-account-created',
+ with: [
+ 'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
+ 'fullName' => $data['customer_name'] ?? 'Quý khách',
+ 'domainName' => $data['domain_name'] ?? '',
+ 'hostingPlan' => $data['hosting_plan'] ?? '',
+ 'serverHost' => $data['server_host'] ?? '',
+ 'ftpUsername' => $data['ftp_username'] ?? '',
+ 'ftpPassword' => $data['ftp_password'] ?? '',
+ 'pleskLogin' => $data['plesk_login'] ?? '',
+ 'pleskPassword' => $data['plesk_password'] ?? '',
+ 'pleskPanelUrl' => $data['plesk_panel_url'] ?? '',
+ 'loginUrl' => $data['login_url'] ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
+ 'supportEmail' => $data['support_email'] ?? config('mail.support_email', 'support@thegioiso.com'),
+ 'supportPhone' => $data['support_phone'] ?? config('mail.support_phone', '1900.xxxx'),
+ ],
+ );
+ }
+
+ public function attachments(): array
+ {
+ return [];
+ }
+
+ private function payload(): array
+ {
+ try {
+ $setting = Setting::where('key', 'setting')->first();
+ $settingValue = $setting ? json_decode((string) $setting->value, true) : [];
+ } catch (\Throwable) {
+ $settingValue = [];
+ }
+
+ return array_merge([
+ 'company_name' => $settingValue['company'] ?? config('app.name', 'The Gioi So'),
+ 'support_email' => $settingValue['email'] ?? config('mail.support_email', 'support@thegioiso.com'),
+ 'support_phone' => $settingValue['hotline'] ?? ($settingValue['phone'] ?? config('mail.support_phone', '1900.xxxx')),
+ 'login_url' => rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap',
+ ], $this->data);
+ }
+}
diff --git a/app/Models/DnsServer.php b/app/Models/DnsServer.php
index a92d94b..38c3358 100644
--- a/app/Models/DnsServer.php
+++ b/app/Models/DnsServer.php
@@ -2,6 +2,7 @@
namespace App\Models;
+use App\Enums\Server\ServerType;
use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
@@ -19,6 +20,7 @@ class DnsServer extends Model
'username',
'api_key',
'status',
+ 'type',
];
public function getStatus(): string
@@ -30,6 +32,13 @@ class DnsServer extends Model
};
}
+ public function servesHosting(): bool
+ {
+ $type = $this->attributes['type'] ?? ServerType::DOMAIN->value;
+
+ return in_array($type, [ServerType::HOSTING->value, ServerType::BOTH->value], true);
+ }
+
protected function scopeIsActive($query)
{
return $query->where('status', 'activated');
diff --git a/app/Models/ModuleHostingAccount.php b/app/Models/ModuleHostingAccount.php
new file mode 100644
index 0000000..0a8af6e
--- /dev/null
+++ b/app/Models/ModuleHostingAccount.php
@@ -0,0 +1,49 @@
+belongsTo(Customer::class);
+ }
+
+ public function service()
+ {
+ return $this->belongsTo(Service::class);
+ }
+
+ public function server()
+ {
+ return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
+ }
+
+ public function domain()
+ {
+ return $this->belongsTo(DnsDomain::class, 'dns_manager_domain_id');
+ }
+}
diff --git a/app/Models/ModuleHostingPleskClient.php b/app/Models/ModuleHostingPleskClient.php
new file mode 100644
index 0000000..a996a3a
--- /dev/null
+++ b/app/Models/ModuleHostingPleskClient.php
@@ -0,0 +1,31 @@
+belongsTo(Customer::class);
+ }
+
+ public function server()
+ {
+ return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
+ }
+}
diff --git a/app/Providers/RepositoryProvider.php b/app/Providers/RepositoryProvider.php
index 3f335bc..746700f 100644
--- a/app/Providers/RepositoryProvider.php
+++ b/app/Providers/RepositoryProvider.php
@@ -82,6 +82,8 @@ use App\Repositories\Module\Hosting\HostingRepositoryInterface as ModuleHostingR
use App\Repositories\Module\HostingServer\HostingServerCachedRepository;
use App\Repositories\Module\HostingServer\HostingServerRepositoryInterface;
+use App\Repositories\Module\HostingAccount\HostingAccountCachedRepository;
+use App\Repositories\Module\HostingAccount\HostingAccountRepositoryInterface;
use App\Repositories\Module\IpAddress\IpAddressCachedRepository;
use App\Repositories\Module\IpAddress\IpAddressRepositoryInterface;
use App\Repositories\Module\Network\NetworkCachedRepository;
@@ -198,6 +200,8 @@ class RepositoryProvider extends ServiceProvider
$this->app->singleton(ModuleHostingRepositoryInterface::class, ModuleHostingCachedRepository::class);
//Hosting Server
$this->app->singleton(HostingServerRepositoryInterface::class, HostingServerCachedRepository::class);
+ //Hosting Account
+ $this->app->singleton(HostingAccountRepositoryInterface::class, HostingAccountCachedRepository::class);
//IpAddress
$this->app->singleton(IpAddressRepositoryInterface::class, IpAddressCachedRepository::class);
//Network
diff --git a/app/Repositories/Module/HostingAccount/HostingAccountCachedRepository.php b/app/Repositories/Module/HostingAccount/HostingAccountCachedRepository.php
new file mode 100644
index 0000000..f01b6a5
--- /dev/null
+++ b/app/Repositories/Module/HostingAccount/HostingAccountCachedRepository.php
@@ -0,0 +1,48 @@
+hostingAccountRepository->getModel();
+ }
+
+ public function filter(
+ array $data = [],
+ array $relationships = [],
+ array $withCount = [],
+ array $select = ['*'],
+ bool $returnQuery = false
+ ) {
+ if (
+ $returnQuery ||
+ !empty($data['paginate'])
+ ) {
+ return $this->hostingAccountRepository
+ ->filter($data, $relationships, $withCount, $select, $returnQuery);
+ }
+
+ $cacheKey = $this->getCacheKey(md5(json_encode([
+ 'filter' => compact('data', 'relationships', 'withCount', 'select')
+ ])));
+
+ return Cache::remember(
+ $cacheKey,
+ $this->cacheTime,
+ fn() =>
+ $this->hostingAccountRepository
+ ->filter($data, $relationships, $withCount, $select, $returnQuery)
+ );
+ }
+}
diff --git a/app/Repositories/Module/HostingAccount/HostingAccountRepository.php b/app/Repositories/Module/HostingAccount/HostingAccountRepository.php
new file mode 100644
index 0000000..f5ad900
--- /dev/null
+++ b/app/Repositories/Module/HostingAccount/HostingAccountRepository.php
@@ -0,0 +1,50 @@
+model
+ ->select($select)
+ ->with($relationships);
+
+ if (!empty($data['customer_id'])) {
+ $query->where('customer_id', $data['customer_id']);
+ }
+
+ if (!empty($data['status'])) {
+ $query->where('status', $data['status']);
+ }
+
+ if (!empty($withCount)) {
+ $query->withCount($withCount);
+ }
+
+ if ($returnQuery) {
+ return $query;
+ }
+
+ if (!empty($data['paginate'])) {
+ return $query->paginate(
+ $data['per_page'] ?? 15
+ );
+ }
+
+ return $query->get();
+ }
+}
diff --git a/app/Repositories/Module/HostingAccount/HostingAccountRepositoryInterface.php b/app/Repositories/Module/HostingAccount/HostingAccountRepositoryInterface.php
new file mode 100644
index 0000000..5dd4e98
--- /dev/null
+++ b/app/Repositories/Module/HostingAccount/HostingAccountRepositoryInterface.php
@@ -0,0 +1,16 @@
+where('customer_id', $customer->id)
+ ->where('dns_manager_server_id', $server->id)
+ ->first();
+
+ if ($existing) {
+ return [
+ 'error' => false,
+ 'data' => [
+ 'id' => $existing->plesk_client_id,
+ 'login' => $existing->login,
+ 'password' => Crypt::decryptString($existing->password),
+ ],
+ ];
+ }
+
+ $login = $this->generateUsername($customer);
+ $password = Helper::general_password();
+
+ $created = $this->createCustomer($server, $customer, $login, $password);
+
+ if (!$created['error']) {
+ $this->persist($customer, $server, $created['data']['id'], $login, $password);
+
+ return ['error' => false, 'data' => ['id' => $created['data']['id'], 'login' => $login, 'password' => $password]];
+ }
+
+ // login already exists on this Plesk box but we have no local record of it (created
+ // out-of-band, or our own table was reset) — Plesk never gives back the old password,
+ // so reset it to one we generate now and persist that, instead of losing it again.
+ if (in_array($created['status'] ?? null, [400, 409], true)) {
+ $existingRemote = $this->findByLogin($server, $login);
+
+ if ($existingRemote) {
+ $reset = $this->resetPassword($server, (int) $existingRemote['id'], $password);
+
+ if ($reset) {
+ $this->persist($customer, $server, (int) $existingRemote['id'], $login, $password);
+
+ return ['error' => false, 'data' => ['id' => (int) $existingRemote['id'], 'login' => $login, 'password' => $password]];
+ }
+
+ Log::error('Plesk client found but password reset failed — reusing without a known password', [
+ 'plesk_client_id' => $existingRemote['id'],
+ 'login' => $login,
+ ]);
+
+ return ['error' => false, 'data' => ['id' => (int) $existingRemote['id'], 'login' => $login, 'password' => null]];
+ }
+ }
+
+ return $created;
+ }
+
+ private function createCustomer(DnsServer $server, Customer $customer, string $login, string $password): array
+ {
+ $param = [
+ 'name' => $customer->fullname ?: $login,
+ 'login' => $login,
+ 'email' => $customer->email,
+ 'password' => $password,
+ 'type' => 'customer',
+ ];
+
+ try {
+ $response = $this->plesk->client($server, 30)
+ ->post($this->plesk->url($server, 'clients'), $param);
+
+ $data = $response->json();
+
+ if ($response->successful() && isset($data['id'], $data['guid'])) {
+ return ['error' => false, 'data' => $data];
+ }
+
+ Log::error('Plesk client creation failed', ['status' => $response->status(), 'response' => $data]);
+
+ return [
+ 'error' => true,
+ 'status' => $response->status(),
+ 'message' => $data['message'] ?? 'Plesk client creation failed',
+ ];
+ } catch (RequestException $e) {
+ Log::error('Plesk client creation failed', ['error' => $e->getMessage(), 'response' => $e->response?->body()]);
+
+ return ['error' => true, 'status' => $e->response?->status(), 'message' => $e->getMessage()];
+ }
+ }
+
+ private function resetPassword(DnsServer $server, int $pleskClientId, string $password): bool
+ {
+ try {
+ $response = $this->plesk->client($server, 15)
+ ->put($this->plesk->url($server, 'clients', "/{$pleskClientId}"), ['password' => $password]);
+
+ return $response->successful();
+ } catch (RequestException $e) {
+ Log::error('Plesk client password reset failed', ['error' => $e->getMessage(), 'plesk_client_id' => $pleskClientId]);
+
+ return false;
+ }
+ }
+
+ private function findByLogin(DnsServer $server, string $login): ?array
+ {
+ try {
+ $response = $this->plesk->client($server, 15)->get($this->plesk->url($server, 'clients'));
+
+ if (!$response->successful()) {
+ return null;
+ }
+
+ foreach ($response->json() ?? [] as $client) {
+ if (($client['login'] ?? null) === $login) {
+ return $client;
+ }
+ }
+ } catch (RequestException $e) {
+ Log::error($e->getMessage());
+ }
+
+ return null;
+ }
+
+ /**
+ * Plesk `login` is a short username, not the email (email is its own field
+ * on the same payload). Derived from the email's local part + customer id
+ * so it's stable and never collides across customers — needed because a
+ * 409 on create is treated as "this customer already has a client".
+ */
+ private function generateUsername(Customer $customer): string
+ {
+ $localPart = strtolower((string) strstr($customer->email, '@', true) ?: 'user');
+ $sanitized = trim((string) preg_replace('/[^a-z0-9]+/', '_', $localPart), '_') ?: 'user';
+
+ return substr($sanitized, 0, 32) . '_' . $customer->id;
+ }
+
+ private function persist(Customer $customer, DnsServer $server, int $pleskClientId, string $login, string $password): void
+ {
+ ModuleHostingPleskClient::query()->updateOrCreate(
+ ['customer_id' => $customer->id, 'dns_manager_server_id' => $server->id],
+ ['plesk_client_id' => $pleskClientId, 'login' => $login, 'password' => Crypt::encryptString($password)]
+ );
+ }
+}
diff --git a/app/Services/Plesk/PleskDomainService.php b/app/Services/Plesk/PleskDomainService.php
new file mode 100644
index 0000000..7a0ffda
--- /dev/null
+++ b/app/Services/Plesk/PleskDomainService.php
@@ -0,0 +1,184 @@
+lastError;
+ }
+
+ public function createDomain(DnsServer $server, array $data = []): array|false
+ {
+ $this->lastError = null;
+
+ if (empty($data['domain_name'])) {
+ $this->lastError = 'Domain name is required';
+ return false;
+ }
+
+ $param = [
+ 'name' => $data['domain_name'],
+ 'description' => '',
+ 'hosting_type' => 'virtual',
+ 'hosting_settings' => [
+ 'ftp_login' => $data['ftp_login'] ?? str_replace('.', '_', $data['domain_name']),
+ 'ftp_password' => $data['ftp_password'] ?? Str::random(12),
+ ],
+ 'plan' => ['name' => $data['plan'] ?? 'Default Domain'],
+ ];
+
+ // Omit ip_addresses/ipv4 unless explicitly given — Plesk assigns its
+ // own default IP for the domain, and a wrong hardcoded IP here gets
+ // rejected outright ("IP address 'x.x.x.x' is not available").
+ if (!empty($data['ip_addresses'])) {
+ $param['ip_addresses'] = $data['ip_addresses'];
+ $param['ipv4'] = $data['ip_addresses'];
+ }
+
+ if (!empty($data['owner_client_id'])) {
+ $param['owner_client'] = ['id' => $data['owner_client_id']];
+ }
+
+ try {
+ $response = $this->plesk->client($server, 30)
+ ->post($this->plesk->url($server, 'domains'), $param);
+
+ $responseData = $response->json();
+
+ Log::info('Response Plesk API Created', ['data' => $responseData]);
+
+ if ($response->successful() && isset($responseData['id'], $responseData['guid'])) {
+ return $responseData + ['hosting_settings' => $param['hosting_settings']];
+ }
+
+ $this->lastError = $responseData['message'] ?? 'Plesk domain creation failed';
+ } catch (RequestException $e) {
+ $this->lastError = $e->response?->json('message') ?? $e->getMessage();
+ Log::error('Plesk domain creation failed', [
+ 'error' => $e->getMessage(),
+ 'response' => $e->response?->body(),
+ 'data' => $param,
+ ]);
+ }
+
+ return false;
+ }
+
+ public function updateDomain(DnsServer $server, int $pleskDomainId, array $data = []): array|false
+ {
+ $this->lastError = null;
+
+ if (empty($pleskDomainId) || empty($data)) {
+ $this->lastError = 'Invalid request';
+ return false;
+ }
+
+ $param = [
+ 'name' => $data['domain_name'],
+ 'description' => '',
+ 'hosting_type' => 'virtual',
+ 'hosting_settings' => [
+ 'ftp_login' => $data['ftp_login'] ?? str_replace('.', '_', $data['domain_name']),
+ 'ftp_password' => $data['ftp_password'] ?? Str::random(12),
+ ],
+ 'plan' => ['name' => $data['plan'] ?? 'Default Domain'],
+ ];
+
+ if (!empty($data['ip_addresses'])) {
+ $param['ip_addresses'] = $data['ip_addresses'];
+ $param['ipv4'] = $data['ip_addresses'];
+ }
+
+ try {
+ $response = $this->plesk->client($server, 30)
+ ->put($this->plesk->url($server, 'domains', "/{$pleskDomainId}"), $param);
+
+ $responseData = $response->json();
+
+ Log::info('Response Plesk API Updated', ['data' => $responseData]);
+
+ if ($response->successful() && isset($responseData['id'], $responseData['guid'])) {
+ return $responseData;
+ }
+
+ $this->lastError = $responseData['message'] ?? 'Plesk domain update failed';
+ } catch (RequestException $e) {
+ $this->lastError = $e->response?->json('message') ?? $e->getMessage();
+ Log::error('Plesk domain update failed', [
+ 'error' => $e->getMessage(),
+ 'response' => $e->response?->body(),
+ 'data' => $param,
+ ]);
+ }
+
+ return false;
+ }
+
+ public function removeDomain(DnsServer $server, int $pleskDomainId): bool
+ {
+ try {
+ $response = $this->plesk->client($server, 30)
+ ->delete($this->plesk->url($server, 'domains', "/{$pleskDomainId}"));
+
+ if ($response->successful()) {
+ Log::info('Xoá domain thành công', ['plesk_id' => $pleskDomainId, 'status' => $response->status()]);
+ return true;
+ }
+
+ Log::error('Xoá domain thất bại', [
+ 'plesk_id' => $pleskDomainId,
+ 'status' => $response->status(),
+ 'response' => $response->body(),
+ ]);
+
+ return false;
+ } catch (\Exception $e) {
+ Log::error('Exception khi xoá domain', ['plesk_id' => $pleskDomainId, 'error' => $e->getMessage()]);
+ return false;
+ }
+ }
+
+ public function getDomainInfo(DnsServer $server, string $domain = ''): array|false
+ {
+ if (empty($domain)) {
+ return false;
+ }
+
+ try {
+ $response = $this->plesk->client($server, 10)
+ ->get($this->plesk->url($server, 'domains'), ['name' => $domain]);
+
+ if ($response->successful()) {
+ foreach ($response->json() ?? [] as $domainInfo) {
+ if (isset($domainInfo['name']) && $domainInfo['name'] === $domain) {
+ return $domainInfo;
+ }
+ }
+ }
+
+ return false;
+ } catch (RequestException $e) {
+ Log::error($e->getMessage());
+ return false;
+ }
+ }
+}
diff --git a/app/Services/Plesk/PleskHostingService.php b/app/Services/Plesk/PleskHostingService.php
new file mode 100644
index 0000000..7f52e06
--- /dev/null
+++ b/app/Services/Plesk/PleskHostingService.php
@@ -0,0 +1,152 @@
+ Create Hosting Subscription ->
+ * Assign Plan -> persist module_hosting_accounts. No database/SSL/mail/git
+ * deploy — that's later phases.
+ */
+class PleskHostingService
+{
+ public function __construct(
+ private PleskClientService $clients,
+ private PleskDomainService $domains,
+ private HostingAccountRepositoryInterface $accounts,
+ private DnsDomainRepositoryInterface $dnsDomains,
+ ) {
+ }
+
+ public function provision(array $data): array
+ {
+ $server = DnsServer::find($data['dns_manager_server_id'] ?? null);
+
+ if (!$server) {
+ return ['error' => true, 'message' => 'Hosting server not found'];
+ }
+
+ if (!$server->servesHosting()) {
+ return ['error' => true, 'message' => 'This server does not support hosting'];
+ }
+
+ $customer = Customer::find($data['customer_id'] ?? null);
+
+ if (!$customer) {
+ return ['error' => true, 'message' => 'Customer not found'];
+ }
+
+ $client = $this->clients->findOrCreateCustomer($server, $customer);
+
+ if ($client['error']) {
+ return $client;
+ }
+
+ $pleskClientId = $client['data']['id'];
+ $pleskLogin = $client['data']['login'];
+ $pleskPassword = $client['data']['password'];
+
+ $ftpLogin = str_replace('.', '_', $data['domain_name']);
+ // Not persisted anywhere — the only record of it is this response, once.
+ $ftpPassword = $data['ftp_password'] ?? Helper::general_password();
+
+ $subscription = $this->domains->createDomain($server, [
+ 'domain_name' => $data['domain_name'],
+ 'ftp_login' => $ftpLogin,
+ 'ftp_password' => $ftpPassword,
+ 'plan' => $data['hosting_plan'] ?? null,
+ 'owner_client_id' => $pleskClientId,
+ ]);
+
+ if (!$subscription) {
+ return [
+ 'error' => true,
+ 'message' => $this->domains->getLastError() ?? 'Failed to create Plesk hosting subscription',
+ ];
+ }
+
+ $domainInfo = $this->domains->getDomainInfo($server, $data['domain_name']) ?: [];
+
+ $dnsManagerDomainId = $data['dns_manager_domain_id'] ?? null;
+
+ if (!$dnsManagerDomainId) {
+ // Plesk's /domains object is the same entity the DNS module tracks —
+ // creating a hosting subscription there already created the DNS zone
+ // on Plesk, so mirror it locally instead of leaving it unlinked.
+ $dnsDomain = $this->dnsDomains->model->firstOrCreate(
+ [
+ 'domain_name' => $data['domain_name'],
+ 'dns_manager_server_id' => $server->id,
+ ],
+ [
+ 'plesk_id' => $subscription['id'],
+ 'is_created' => 1,
+ ]
+ );
+
+ $dnsManagerDomainId = $dnsDomain->id;
+ }
+
+ $account = $this->accounts->create([
+ 'customer_id' => $customer->id,
+ 'service_id' => $data['service_id'] ?? null,
+ 'dns_manager_server_id' => $server->id,
+ 'dns_manager_domain_id' => $dnsManagerDomainId,
+ 'plesk_client_id' => $pleskClientId,
+ 'plesk_subscription_id' => $subscription['id'],
+ 'plesk_guid' => $subscription['guid'],
+ 'hosting_plan' => $data['hosting_plan'] ?? null,
+ 'ftp_username' => $subscription['hosting_settings']['ftp_login'] ?? $ftpLogin,
+ 'system_username' => $subscription['hosting_settings']['ftp_login'] ?? $ftpLogin,
+ 'document_root' => $domainInfo['www_root'] ?? null,
+ 'status' => 'active',
+ ]);
+
+ $this->notify($customer, $server, $account, $data['domain_name'], $ftpPassword, $pleskLogin, $pleskPassword);
+
+ return ['error' => false, 'data' => $account, 'ftp_password' => $ftpPassword];
+ }
+
+ private function notify(
+ Customer $customer,
+ DnsServer $server,
+ ModuleHostingAccount $account,
+ string $domainName,
+ string $ftpPassword,
+ string $pleskLogin,
+ ?string $pleskPassword,
+ ): void {
+ $shared = [
+ 'customer_name' => $customer->fullname,
+ 'customer_email' => $customer->email,
+ 'domain_name' => $domainName,
+ 'hosting_plan' => $account->hosting_plan,
+ 'server_name' => $server->name,
+ 'ftp_username' => $account->ftp_username,
+ 'ftp_password' => $ftpPassword,
+ 'plesk_login' => $pleskLogin,
+ 'plesk_password' => $pleskPassword,
+ // Plesk's actual login page — not the :8443 REST API entry point.
+ 'plesk_panel_url' => 'https://' . $server->ip_address . '/login_up.php',
+ 'created_at' => now()->format('d/m/Y H:i:s'),
+ ];
+
+ dispatch(new HostingAccountCreatedJob(array_merge($shared, [
+ 'server_host' => $server->ip_address,
+ ])));
+
+ dispatch(new HostingAccountCreatedAdminNotificationJob(array_merge($shared, [
+ 'server_ip' => $server->ip_address,
+ 'plesk_client_id' => $account->plesk_client_id,
+ 'plesk_subscription_id' => $account->plesk_subscription_id,
+ ])));
+ }
+}
diff --git a/app/Services/Plesk/PleskService.php b/app/Services/Plesk/PleskService.php
new file mode 100644
index 0000000..18cd0e0
--- /dev/null
+++ b/app/Services/Plesk/PleskService.php
@@ -0,0 +1,29 @@
+ ':8443/api/v2/clients',
+ 'domains' => ':8443/api/v2/domains',
+ ];
+
+ public function client(DnsServer $server, int $timeout = 30): PendingRequest
+ {
+ return Http::withoutVerifying()
+ ->withBasicAuth($server->username, Crypt::decryptString($server->password))
+ ->timeout($timeout);
+ }
+
+ public function url(DnsServer $server, string $entryPoint, string $suffix = ''): string
+ {
+ return BaseHelper::url($server->ip_address) . $this->entryPoints[$entryPoint] . $suffix;
+ }
+}
diff --git a/app/Services/ServerService.php b/app/Services/ServerService.php
index 85607f7..cccadb2 100644
--- a/app/Services/ServerService.php
+++ b/app/Services/ServerService.php
@@ -5,11 +5,11 @@ namespace App\Services;
use App\Facades\BaseHelperFacade as BaseHelper;
use App\Models\DnsServer;
use App\Repositories\DnsManager\Server\DnsServerRepositoryInterface;
+use App\Services\Plesk\PleskDomainService;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Facades\Crypt;
use Illuminate\Support\Facades\Log;
-use Illuminate\Support\Str;
class ServerService
{
@@ -33,7 +33,8 @@ class ServerService
];
public function __construct(
- public DnsServerRepositoryInterface $serverRepository
+ public DnsServerRepositoryInterface $serverRepository,
+ private PleskDomainService $pleskDomainService,
) {
$this->server = $this->getServer();
}
@@ -205,32 +206,15 @@ class ServerService
public function getDomainInfo(string $domain = ''): array|false
{
- if (empty($domain)) {
+ if (empty($domain) || empty($this->server)) {
return false;
}
- try {
- $response = Http::withoutVerifying()
- ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password))
- ->timeout(10)
- ->get(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'], ['name' => $domain]);
+ $result = $this->pleskDomainService->getDomainInfo($this->server, $domain);
- $this->resetServer();
+ $this->resetServer();
- if ($response->successful()) {
- $domains = $response->json();
- foreach ($domains as $domainInfo) {
- if (isset($domainInfo['name']) && $domainInfo['name'] === $domain) {
- return $domainInfo;
- }
- }
- }
-
- return false;
- } catch (RequestException $e) {
- Log::error($e->getMessage());
- return false;
- }
+ return $result;
}
public function createDomain(array $data = []): array|false
@@ -243,42 +227,11 @@ class ServerService
$this->server = $this->serverRepository->find($data['dns_manager_server_id']);
}
- $param = [
- 'name' => $data['domain_name'],
- 'description' => '',
- 'hosting_type' => 'virtual',
- 'hosting_settings' => [
- 'ftp_login' => str_replace('.', '_', $data['domain_name']),
- 'ftp_password' => Str::random(12),
- ],
- 'ip_addresses' => ['103.42.58.124'],
- 'ipv4' => ['103.42.58.124'],
- 'plan' => ['name' => 'Default Domain'],
- ];
+ $result = $this->pleskDomainService->createDomain($this->server, $data);
- try {
- $response = Http::withoutVerifying()
- ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password))
- ->timeout(30)
- ->post(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'], $param);
+ $this->resetServer();
- $this->resetServer();
- $responseData = $response->json();
-
- Log::info('Response Plesk API Created', ['data' => $responseData]);
-
- if ($response->successful() && isset($responseData['id'], $responseData['guid'])) {
- return $responseData;
- }
- } catch (RequestException $e) {
- Log::error('Plesk domain creation failed', [
- 'error' => $e->getMessage(),
- 'response' => $e->response?->body(),
- 'data' => $param,
- ]);
- }
-
- return false;
+ return $result;
}
public function updateDomain(int $pleskDomainId, array $data = []): array|false
@@ -291,42 +244,11 @@ class ServerService
$this->server = $this->serverRepository->find($data['dns_manager_server_id']);
}
- $param = [
- 'name' => $data['domain_name'],
- 'description' => '',
- 'hosting_type' => 'virtual',
- 'hosting_settings' => [
- 'ftp_login' => str_replace('.', '_', $data['domain_name']),
- 'ftp_password' => Str::random(12),
- ],
- 'ip_addresses' => ['103.42.58.124'],
- 'ipv4' => ['103.42.58.124'],
- 'plan' => ['name' => 'Default Domain'],
- ];
+ $result = $this->pleskDomainService->updateDomain($this->server, $pleskDomainId, $data);
- try {
- $response = Http::withoutVerifying()
- ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password))
- ->timeout(30)
- ->put(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'] . "/{$pleskDomainId}", $param);
+ $this->resetServer();
- $this->resetServer();
- $responseData = $response->json();
-
- Log::info('Response Plesk API Updated', ['data' => $responseData]);
-
- if ($response->successful() && isset($responseData['id'], $responseData['guid'])) {
- return $responseData;
- }
- } catch (RequestException $e) {
- Log::error('Plesk domain update failed', [
- 'error' => $e->getMessage(),
- 'response' => $e->response?->body(),
- 'data' => $param,
- ]);
- }
-
- return false;
+ return $result;
}
public function removeDomain(int $pleskDomainId): bool
@@ -335,30 +257,11 @@ class ServerService
return false;
}
- try {
- $response = Http::withoutVerifying()
- ->withBasicAuth($this->server->username, Crypt::decryptString($this->server->password))
- ->timeout(30)
- ->delete(BaseHelper::url($this->server->ip_address) . $this->entry_points['domain'] . "/{$pleskDomainId}");
+ $result = $this->pleskDomainService->removeDomain($this->server, $pleskDomainId);
- $this->resetServer();
+ $this->resetServer();
- if ($response->successful()) {
- Log::info('Xoá domain thành công', ['plesk_id' => $pleskDomainId, 'status' => $response->status()]);
- return true;
- }
-
- Log::error('Xoá domain thất bại', [
- 'plesk_id' => $pleskDomainId,
- 'status' => $response->status(),
- 'response' => $response->body(),
- ]);
-
- return false;
- } catch (\Exception $e) {
- Log::error('Exception khi xoá domain', ['plesk_id' => $pleskDomainId, 'error' => $e->getMessage()]);
- return false;
- }
+ return $result;
}
public function addServerById(int $serverId): bool
diff --git a/database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php b/database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php
new file mode 100644
index 0000000..4386bbd
--- /dev/null
+++ b/database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php
@@ -0,0 +1,26 @@
+string('type', 20)->default('domain')->after('status');
+ });
+
+ // every existing server already serves DNS today
+ DB::table('dns_manager_servers')->update(['type' => 'domain']);
+ }
+
+ public function down(): void
+ {
+ Schema::table('dns_manager_servers', function (Blueprint $table) {
+ $table->dropColumn('type');
+ });
+ }
+};
diff --git a/database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php b/database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php
new file mode 100644
index 0000000..2e30e39
--- /dev/null
+++ b/database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php
@@ -0,0 +1,44 @@
+id();
+
+ $table->unsignedBigInteger('customer_id')->index();
+ $table->unsignedBigInteger('service_id')->nullable()->index();
+ $table->unsignedBigInteger('dns_manager_server_id')->index();
+ $table->unsignedBigInteger('dns_manager_domain_id')->nullable()->index();
+
+ $table->unsignedInteger('plesk_client_id')->nullable();
+ $table->unsignedInteger('plesk_subscription_id')->nullable()->index();
+ $table->string('plesk_guid', 64)->nullable();
+
+ $table->string('hosting_plan', 100)->nullable();
+ $table->string('ftp_username', 100)->nullable();
+ $table->string('system_username', 100)->nullable();
+ $table->string('document_root', 255)->nullable();
+
+ // pending -> active -> suspended -> terminated (mirrors services.status)
+ $table->string('status', 20)->default('pending')->index();
+
+ $table->timestamps();
+
+ $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
+ $table->foreign('service_id')->references('id')->on('services')->onDelete('set null');
+ $table->foreign('dns_manager_server_id')->references('id')->on('dns_manager_servers')->onDelete('restrict');
+ $table->foreign('dns_manager_domain_id')->references('id')->on('dns_manager_domain')->onDelete('set null');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('module_hosting_accounts');
+ }
+};
diff --git a/database/migrations/2026_08_06_140000_create_module_hosting_plesk_clients_table.php b/database/migrations/2026_08_06_140000_create_module_hosting_plesk_clients_table.php
new file mode 100644
index 0000000..971a8cd
--- /dev/null
+++ b/database/migrations/2026_08_06_140000_create_module_hosting_plesk_clients_table.php
@@ -0,0 +1,36 @@
+id();
+
+ $table->unsignedBigInteger('customer_id')->index();
+ $table->unsignedBigInteger('dns_manager_server_id')->index();
+ $table->unsignedInteger('plesk_client_id')->index();
+
+ $table->string('login', 100);
+ // Plesk panel login password (Crypt::encryptString) — separate credential
+ // from the FTP/hosting password, which is never stored anywhere.
+ $table->text('password');
+
+ $table->timestamps();
+
+ $table->unique(['dns_manager_server_id', 'customer_id'], 'hosting_plesk_clients_server_customer_unique');
+
+ $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
+ $table->foreign('dns_manager_server_id')->references('id')->on('dns_manager_servers')->onDelete('cascade');
+ });
+ }
+
+ public function down(): void
+ {
+ Schema::dropIfExists('module_hosting_plesk_clients');
+ }
+};
diff --git a/database/seeders/RolesAndPermissions.php b/database/seeders/RolesAndPermissions.php
index 56c2c85..1acef0e 100644
--- a/database/seeders/RolesAndPermissions.php
+++ b/database/seeders/RolesAndPermissions.php
@@ -139,6 +139,12 @@ class RolesAndPermissions extends Seeder
'domain.destroy',
'domain.manager',
+ // Hosting
+ 'hosting.view',
+ 'hosting.create',
+ 'hosting.edit',
+ 'hosting.destroy',
+
// Vai trò & Phân quyền
'role.view',
'role.create',
diff --git a/resources/views/emails/admin/hosting-account-created.blade.php b/resources/views/emails/admin/hosting-account-created.blade.php
new file mode 100644
index 0000000..7e43751
--- /dev/null
+++ b/resources/views/emails/admin/hosting-account-created.blade.php
@@ -0,0 +1,130 @@
+
+
+
+
+
+ Thông báo hosting mới
+
+
+
+
+
+
+
+
+
Thông tin khách hàng
+
+
+ | Tên khách hàng |
+ {{ $customer_name ?? 'N/A' }} |
+
+
+ | Email |
+ {{ $customer_email ?? 'N/A' }} |
+
+
+
+
Thông tin dịch vụ Hosting
+
+
+ | Tên miền |
+ {{ $domain_name ?? 'N/A' }} |
+
+
+ | Gói hosting |
+ {{ $hosting_plan ?? 'N/A' }} |
+
+
+ | Server |
+ {{ $server_name ?? 'N/A' }} ({{ $server_ip ?? 'N/A' }}) |
+
+
+ | Plesk Client ID |
+ {{ $plesk_client_id ?? 'N/A' }} |
+
+
+ | Plesk Subscription ID |
+ {{ $plesk_subscription_id ?? 'N/A' }} |
+
+
+ | Trạng thái |
+ ✅ Active |
+
+
+
+
Tài khoản FTP / System User (đã gửi cho khách)
+
+
+ 👤 FTP Username
+ {{ $ftp_username ?? 'N/A' }}
+
+ @if(!empty($ftp_password))
+
+ 🔑 FTP Password
+ {{ $ftp_password }}
+
+ @endif
+
+
+
Tài khoản đăng nhập Plesk Panel của khách hàng
+
+
+ 🌐 Panel URL
+ {{ $plesk_panel_url ?? 'N/A' }}
+
+
+ 👤 Login
+ {{ $plesk_login ?? 'N/A' }}
+
+ @if(!empty($plesk_password))
+
+ 🔑 Password
+ {{ $plesk_password }}
+
+ @endif
+
+
+ Đây là tài khoản Plesk riêng của khách hàng (đã gửi kèm cho khách), khác với tài khoản quản trị
+ của server dùng chung — tài khoản quản trị server không được đính kèm trong email này vì lý do
+ bảo mật.
+
+
+
+
+
+
+
+
diff --git a/resources/views/emails/customer/hosting-account-created.blade.php b/resources/views/emails/customer/hosting-account-created.blade.php
new file mode 100644
index 0000000..dffcb21
--- /dev/null
+++ b/resources/views/emails/customer/hosting-account-created.blade.php
@@ -0,0 +1,231 @@
+@php
+ $appName = $appName ?? config('app.name', 'Thế Giới Số');
+ $fullName = $fullName ?? 'Quý khách';
+ $domainName = $domainName ?? '';
+ $hostingPlan = $hostingPlan ?? '';
+ $serverHost = $serverHost ?? '';
+ $ftpUsername = $ftpUsername ?? '';
+ $ftpPassword = $ftpPassword ?? '';
+ $pleskLogin = $pleskLogin ?? '';
+ $pleskPassword = $pleskPassword ?? '';
+ $pleskPanelUrl = $pleskPanelUrl ?? '';
+ $loginUrl = $loginUrl ?? rtrim(config('app.frontend_url', ''), '/') . '/dang-nhap';
+ $supportEmail = $supportEmail ?? 'support@tgs.com.vn';
+ $supportPhone = $supportPhone ?? '1900. 6119';
+@endphp
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+ 🎉
+
+ Hosting của bạn đã sẵn sàng
+
+
+ Gói hosting cho tên miền {{ $domainName }} đã được khởi tạo thành công
+
+ |
+
+
+
+
+
+
+ |
+
+ Xin chào {{ $fullName }},
+
+
+ Dịch vụ hosting của bạn tại {{ $appName }} đã được khởi tạo thành công. Dưới đây là thông tin truy cập:
+
+
+
+
+
+ |
+ Tên miền: {{ $domainName }}
+ Gói hosting: {{ $hostingPlan }}
+ @if($serverHost)
+ Máy chủ / FTP Host: {{ $serverHost }}
+ @endif
+ @if($ftpUsername)
+ Tài khoản FTP: {{ $ftpUsername }}
+ @endif
+ @if($ftpPassword)
+ Mật khẩu FTP: {{ $ftpPassword }}
+ @endif
+ Trạng thái: Đang hoạt động ✓
+ |
+
+
+
+
+ @if($ftpPassword)
+
+ ⚠️ Vì lý do bảo mật, mật khẩu FTP chỉ được gửi một lần duy nhất qua email và không được lưu trữ trên hệ thống. Vui lòng lưu lại ở nơi an toàn.
+
+
+ @endif
+ @if($pleskLogin)
+ Tài khoản đăng nhập Plesk Panel (để tự quản lý domain, DNS, database, mail...):
+
+
+
+
+ |
+
+ Tên đăng nhập: {{ $pleskLogin }}
+ @if($pleskPassword)
+ Mật khẩu: {{ $pleskPassword }}
+ @endif
+ |
+
+
+
+
+ @endif
+
+ Bạn có thể đăng nhập vào cổng quản lý để theo dõi và quản lý dịch vụ của mình.
+
+
+
+
+
+ Nếu bạn không yêu cầu dịch vụ này, vui lòng liên hệ ngay với chúng tôi qua {{ $supportEmail }} hoặc {{ $supportPhone }}.
+
+
+
+ Email này được gửi tự động. Vui lòng không trả lời email này.
+
+
+ |
+
+
+
+ |
+
+
+
+ |
+
+
+
+
+
+
+ |
+
+ |
+
+
+ | |
+
+
+ |
+
+ |
+
+
+ | |
+
+
+ |
+
+ |
+
+
+
+ |
+
+
+ |
+
+ |
+
+
+
+ |
+
+
+
diff --git a/routes/api.php b/routes/api.php
index bd0d1c2..ff26e12 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -25,6 +25,7 @@ use App\Http\Controllers\Api\Admin\VCenter\VCenterController;
use App\Http\Controllers\Api\Admin\DnsManager\ServerController as DnsServerController;
use App\Http\Controllers\Api\Admin\DnsManager\DomainController as DnsDomainController;
use App\Http\Controllers\Api\Admin\DnsManager\RecordController as DnsRecordController;
+use App\Http\Controllers\Api\Admin\Hosting\HostingAccountController;
use App\Http\Controllers\Api\Admin\OsManager\OsGroupController;
use App\Http\Controllers\Api\Admin\OsManager\OsController;
use App\Http\Controllers\Api\Admin\IpPool\IpPoolController;
@@ -420,6 +421,20 @@ Route::prefix('v1')
Route::put('{id}', 'update')->middleware('perm:domain.manager');
});
+ // Hosting Manager (module: hosting)
+ Route::prefix('hosting-accounts')
+ ->controller(HostingAccountController::class)
+ ->group(function () {
+ Route::get('/', 'index')->middleware('perm:hosting.view');
+ Route::get('{id}', 'show')->middleware('perm:hosting.view');
+ Route::post('/', 'store')->middleware('perm:hosting.create');
+ Route::put('{id}', 'update')->middleware('perm:hosting.edit');
+ Route::delete('{id}', 'destroy')->middleware('perm:hosting.destroy');
+ Route::post('{id}/suspend', 'suspend')->middleware('perm:hosting.edit');
+ Route::post('{id}/unsuspend', 'unsuspend')->middleware('perm:hosting.edit');
+ Route::post('{id}/reset-ftp-password', 'resetFtpPassword')->middleware('perm:hosting.edit');
+ });
+
// InfraManagement (jumphost, vcenter config)
Route::prefix('infra-management')
->controller(InfraManagementController::class)