This commit is contained in:
baodev0
2026-06-22 09:55:11 +07:00
parent 0c436153c6
commit 776b1a6807
12 changed files with 491 additions and 10 deletions
@@ -324,6 +324,64 @@ class CustomerController extends Controller
}
}
public function sendPasswordEmail(Request $request, string $uuid): JsonResponse
{
$request->validate([
'password' => 'required|string|min:8|max:64',
]);
$customer = $this->customerRepository->findBy('uuid', $uuid);
if (!$customer) {
return response()->json([
'error' => true,
'message' => "Can't find customer by UUID #" . $uuid,
], 404);
}
$password = (string) $request->input('password');
try {
DB::beginTransaction();
$this->customerRepository->update($customer->id, [
'password' => Hash::make($password),
]);
\Illuminate\Support\Facades\Mail::to($customer->email)->send(
new \App\Mail\Customer\PasswordResetByAdmin([
'fullname' => $customer->fullname,
'email' => $customer->email,
'password' => $password,
])
);
Log::create([
'agent' => 'admin',
'client_ip' => $request->ip(),
'description' => sprintf(
'Admin gửi lại mật khẩu mới cho customer %s',
$customer->email,
),
'customer_id' => $customer->id,
]);
DB::commit();
return response()->json([
'error' => false,
'message' => 'Đã gửi mật khẩu mới cho khách hàng.',
]);
} catch (\Throwable $e) {
DB::rollBack();
return response()->json([
'error' => true,
'message' => $e->getMessage(),
], 500);
}
}
public function addCredit(Request $request, string $uuid): JsonResponse
{
$request->validate([
@@ -562,6 +620,82 @@ class CustomerController extends Controller
]);
}
public function getDnsDomains(string $uuid): JsonResponse
{
$customer = $this->customerRepository->findBy('uuid', $uuid);
if (!$customer) {
return response()->json([
'error' => true,
'message' => "Can't find customer by UUID #" . $uuid,
], 404);
}
$domains = \App\Models\DnsDomain::query()
->with('server:id,name,ip_address')
->withCount(['ns', 'record'])
->whereHas('customer', fn ($q) => $q->where('customers.id', $customer->id))
->orderByDesc('id')
->get()
->map(function ($d) {
return [
'id' => $d->id,
'domain_name' => $d->domain_name,
'is_created' => (int) $d->is_created,
'plesk_id' => $d->plesk_id,
'ns_count' => (int) ($d->ns_count ?? 0),
'record_count' => (int) ($d->record_count ?? 0),
'server' => $d->server ? [
'id' => $d->server->id,
'name' => $d->server->name,
'ip_address' => $d->server->ip_address,
] : null,
'created_at' => $d->created_at,
'updated_at' => $d->updated_at,
];
});
return response()->json([
'error' => false,
'data' => $domains,
]);
}
public function getDnsDomainRecords(string $uuid, int $domainId): JsonResponse
{
$customer = $this->customerRepository->findBy('uuid', $uuid);
if (!$customer) {
return response()->json([
'error' => true,
'message' => "Can't find customer by UUID #" . $uuid,
], 404);
}
$owns = \Illuminate\Support\Facades\DB::table('dns_domain_user')
->where('domain_id', $domainId)
->where('user_id', $customer->id)
->exists();
if (!$owns) {
return response()->json([
'error' => true,
'message' => 'Domain không thuộc về khách hàng này',
], 403);
}
$records = \App\Models\DnsRecord::query()
->where('dns_manager_domain_id', $domainId)
->orderBy('type')
->orderBy('name')
->get();
return response()->json([
'error' => false,
'data' => $records,
]);
}
public function getServices(string $uuid, Request $request): JsonResponse
{
$services = $this->serviceRepository->filter(
@@ -4,12 +4,14 @@ namespace App\Http\Controllers\Api\Client;
use App\Http\Controllers\Controller;
use App\Http\Requests\Api\Client\ClientAuthRequest;
use App\Http\Requests\Api\Client\ClientChangePasswordRequest;
use App\Http\Requests\Api\Client\ClientRegisterRequest;
use App\Http\Requests\Api\Client\ClientUpdateRequest;
use App\Http\Resources\Api\Client\ClientResource;
use App\Services\ActivityLogger;
use App\Services\Api\Client\ClientAuthService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Hash;
use Tymon\JWTAuth\Facades\JWTAuth;
use Tymon\JWTAuth\Exceptions\JWTException;
@@ -307,6 +309,68 @@ class ClientController extends Controller
}
}
public function forgotPassword(Request $request)
{
$request->validate([
'email' => ['required', 'email', 'max:255'],
]);
$email = strtolower(trim((string) $request->input('email')));
$customer = \App\Models\Customer::whereRaw('LOWER(email) = ?', [$email])->first();
if (! $customer) {
// Do not disclose whether the email exists.
return response()->json([
'error' => false,
'message' => 'Nếu email tồn tại, mật khẩu mới đã được gửi đến hộp thư của bạn.',
]);
}
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789!@#$%^&*';
$newPassword = '';
$max = strlen($alphabet) - 1;
for ($i = 0; $i < 16; $i++) {
$newPassword .= $alphabet[random_int(0, $max)];
}
try {
\Illuminate\Support\Facades\DB::beginTransaction();
$customer->password = Hash::make($newPassword);
$customer->save();
\Illuminate\Support\Facades\Mail::to($customer->email)->send(
new \App\Mail\Customer\PasswordResetByAdmin([
'fullname' => $customer->fullname,
'email' => $customer->email,
'password' => $newPassword,
])
);
$this->activityLogger->log(
event: 'password_forgot',
logName: 'Customer',
description: "Customer {$customer->email} yêu cầu cấp lại mật khẩu",
subject: $customer,
);
\Illuminate\Support\Facades\DB::commit();
} catch (\Throwable) {
\Illuminate\Support\Facades\DB::rollBack();
return response()->json([
'error' => true,
'message' => 'Không thể gửi email, vui lòng thử lại sau.',
], 500);
}
return response()->json([
'error' => false,
'message' => 'Mật khẩu mới đã được gửi đến email của bạn.',
]);
}
public function update(ClientUpdateRequest $request)
{
$user = auth('client')->user();
@@ -335,4 +399,31 @@ class ClientController extends Controller
],
]);
}
public function changePassword(ClientChangePasswordRequest $request)
{
$user = auth('client')->user();
if (!Hash::check($request->input('current_password'), $user->password)) {
return response()->json([
'error' => true,
'message' => 'Mật khẩu hiện tại không chính xác',
], 422);
}
$user->password = bcrypt($request->input('password'));
$user->save();
$this->activityLogger->log(
event: 'password_change',
logName: 'Customer',
description: "Customer {$user->email} đã đổi mật khẩu",
subject: $user,
);
return response()->json([
'error' => false,
'message' => 'Đổi mật khẩu thành công',
]);
}
}
@@ -0,0 +1,32 @@
<?php
namespace App\Http\Requests\Api\Client;
use Illuminate\Foundation\Http\FormRequest;
class ClientChangePasswordRequest extends FormRequest
{
public function authorize(): bool
{
return auth('client')->check();
}
public function rules(): array
{
return [
'current_password' => 'required|string',
'password' => 'required|string|min:8|confirmed|different:current_password',
];
}
public function messages(): array
{
return [
'current_password.required' => 'Mật khẩu hiện tại là bắt buộc',
'password.required' => 'Mật khẩu mới là bắt buộc',
'password.min' => 'Mật khẩu mới phải có ít nhất 8 ký tự',
'password.confirmed' => 'Xác nhận mật khẩu không khớp',
'password.different' => 'Mật khẩu mới phải khác mật khẩu hiện tại',
];
}
}
@@ -0,0 +1,66 @@
<?php
namespace App\Mail\Customer;
use App\Models\Setting;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PasswordResetByAdmin extends Mailable
{
use Queueable, SerializesModels;
public function __construct(public array $data) {}
public function envelope(): Envelope
{
$appName = $this->payload()['company_name'] ?? config('app.name', 'The Gioi So');
return new Envelope(
subject: 'Mật khẩu của bạn đã được cập nhật — ' . $appName,
);
}
public function content(): Content
{
$data = $this->payload();
return new Content(
view: 'emails.customer.password-reset-admin',
with: [
'appName' => $data['company_name'] ?? config('app.name', 'The Gioi So'),
'fullName' => $data['fullname'] ?? 'Quý khách',
'email' => $data['email'] ?? '',
'password' => $data['password'] ?? '',
'loginUrl' => $data['login_url'] ?? rtrim((string) env('FRONTEND_URL', config('app.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((string) env('FRONTEND_URL', config('app.url')), '/') . '/dang-nhap',
], $this->data);
}
}
+21
View File
@@ -124,6 +124,27 @@ class Customer extends Authenticatable implements JWTSubject
return $this->hasMany(ModuleVmware::class);
}
/**
* Quan hệ N-N với DnsDomain (DNS Manager) qua bảng pivot dns_domain_user.
*/
public function dnsDomain()
{
return $this->belongsToMany(
DnsDomain::class,
'dns_domain_user',
'user_id',
'domain_id',
)->withTimestamps();
}
/**
* Quan hệ với Service (tbl.services).
*/
public function service()
{
return $this->hasMany(Service::class);
}
/**
* Quan hệ với Invoice.
*/
-6
View File
@@ -14,17 +14,11 @@ class DnsDomain extends Model
protected $fillable = [
'domain_name',
'user_id',
'dns_manager_server_id',
'is_created',
'plesk_id',
];
public function user()
{
return $this->belongsTo(User::class);
}
public function customer()
{
return $this->belongsToMany(Customer::class, 'dns_domain_user', 'domain_id', 'user_id')->withTimestamps();
@@ -52,6 +52,54 @@ class CustomerRepository extends BaseRepository implements CustomerRepositoryInt
if (isset($data['phone']) && ! empty($data['phone'])) {
$model = $model->where("phone", "LIKE", "%" . $data['phone'] . "%");
}
if (isset($data['service_type']) && ! empty($data['service_type'])) {
$types = is_array($data['service_type'])
? $data['service_type']
: array_filter(array_map('trim', explode(',', (string) $data['service_type'])));
$simpleMap = [
'hosting' => 'hosting',
'vps' => 'vps',
'domain' => 'domain',
'service' => 'service',
];
$simpleRelations = array_values(array_intersect_key($simpleMap, array_flip($types)));
$hasDns = in_array('dns', $types, true);
if (! empty($simpleRelations) || $hasDns) {
$dnsIds = $hasDns
? \Illuminate\Support\Facades\DB::table('dns_domain_user')->pluck('user_id')
: collect();
$model = $model->where(function ($q) use ($simpleRelations, $dnsIds) {
foreach ($simpleRelations as $relation) {
$q->orWhereHas($relation);
}
if ($dnsIds->isNotEmpty()) {
$q->orWhereIn('customers.id', $dnsIds);
}
});
}
}
if (isset($data['domain']) && ! empty($data['domain'])) {
$domain = trim((string) $data['domain']);
$dnsCustomerIds = \Illuminate\Support\Facades\DB::table('dns_domain_user')
->join('dns_manager_domain', 'dns_manager_domain.id', '=', 'dns_domain_user.domain_id')
->where('dns_manager_domain.domain_name', 'like', "%{$domain}%")
->pluck('dns_domain_user.user_id');
$model = $model->where(function ($q) use ($domain, $dnsCustomerIds) {
$q->whereHas('domain', function ($sub) use ($domain) {
$sub->where('name', 'like', "%{$domain}%");
});
if ($dnsCustomerIds->isNotEmpty()) {
$q->orWhereIn('customers.id', $dnsCustomerIds);
}
});
}
if (isset($data['status']) && ! empty($data['status']) && $data['status'] != 'full') {
$status = (string) $data['status'];
$statusValue = is_numeric($status)
@@ -193,10 +193,6 @@ class AdminZoneDnsSqlImporter
'updated_at' => $this->normalizeDateTime($row['updated_at'] ?? null) ?? now(),
];
if (Schema::hasColumn('dns_manager_domain', 'user_id')) {
$payload['user_id'] = $existing?->user_id ?? 0;
}
if ($preview) {
$summary['dns_domains'][$existing ? 'updated' : 'created']++;
continue;
@@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (Schema::hasTable('dns_manager_domain') && Schema::hasColumn('dns_manager_domain', 'user_id')) {
Schema::table('dns_manager_domain', function (Blueprint $table) {
try {
$table->dropIndex(['user_id']);
} catch (\Throwable) {
// index may have a different generated name; ignore
}
$table->dropColumn('user_id');
});
}
}
public function down(): void
{
if (Schema::hasTable('dns_manager_domain') && ! Schema::hasColumn('dns_manager_domain', 'user_id')) {
Schema::table('dns_manager_domain', function (Blueprint $table) {
$table->tinyInteger('user_id')->default(0)->index()->after('domain_name');
});
}
}
};
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cập nhật mật khẩu — {{ $appName }}</title>
<style>
* { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; background: #f0f4f8; margin: 0; padding: 0; color: #374151; }
.wrapper { max-width: 620px; margin: 30px auto; }
.card { background: #fff; border-radius: 12px; overflow: hidden; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
.header { background: linear-gradient(135deg, #16a34a 0%, #15803d 100%); padding: 32px 40px; text-align: center; }
.header .icon { font-size: 44px; margin-bottom: 8px; }
.header h1 { color: #fff; margin: 0; font-size: 22px; font-weight: 700; }
.body { padding: 32px 40px; line-height: 1.6; }
.credentials { background: #f9fafb; border: 1px dashed #d1d5db; border-radius: 8px; padding: 16px 18px; margin: 18px 0; font-size: 14px; }
.credentials .row { margin: 4px 0; }
.credentials .value { font-family: 'Menlo','Consolas',monospace; font-weight: 700; color: #111827; }
.footer { text-align: center; padding: 18px 40px; border-top: 1px solid #f3f4f6; font-size: 12px; color: #9ca3af; }
.cta-wrap { text-align: center; margin: 24px 0 4px; }
.cta-btn { display: inline-block; background-color: #16a34a; color: #ffffff !important; text-decoration: none; padding: 12px 26px; border-radius: 8px; font-size: 14px; font-weight: 600; }
</style>
</head>
<body>
<div class="wrapper">
<div class="card">
<div class="header">
<div class="icon">🔐</div>
<h1>Mật khẩu của bạn đã được cập nhật</h1>
</div>
<div class="body">
<p style="margin:0 0 12px 0;">
Xin chào <strong>{{ $fullName }}</strong>,
</p>
<p style="margin:0 0 12px 0;">
Quản trị viên của <strong>{{ $appName }}</strong> vừa cấp lại mật khẩu mới cho tài khoản của bạn.
Vui lòng sử dụng thông tin bên dưới để đăng nhập và đổi lại mật khẩu của bạn ngay sau đó.
</p>
<div class="credentials">
<div class="row">Tài khoản: <span class="value">{{ $email }}</span></div>
<div class="row">Mật khẩu mới: <span class="value">{{ $password }}</span></div>
</div>
<div class="cta-wrap">
<a href="{{ $loginUrl }}" class="cta-btn">Đăng nhập ngay</a>
</div>
<p style="margin:18px 0 0 0;color:#6b7280;">
Nếu bạn không yêu cầu thao tác này, vui lòng liên hệ ngay với chúng tôi qua
<strong>{{ $supportEmail }}</strong>.
</p>
</div>
<div class="footer">
Email này được gửi tự động. Vui lòng không trả lời email này.<br>
Cần hỗ trợ? Liên hệ <strong>{{ $supportEmail }}</strong> hoặc <strong>{{ $supportPhone }}</strong>
</div>
</div>
</div>
</body>
</html>
+3
View File
@@ -128,6 +128,7 @@ Route::prefix('v1')
Route::delete('{uuid}', 'destroy')->middleware('perm:customer.destroy');
Route::delete('{uuid}/destroy', 'destroy')->middleware('perm:customer.destroy');
Route::post('{uuid}/reset-password', 'resetPassword')->middleware('perm:customer.edit');
Route::post('{uuid}/send-password', 'sendPasswordEmail')->middleware('perm:customer.edit');
Route::post('{uuid}/add-credit', 'addCredit')->middleware('perm:customer.edit');
Route::post('{uuid}/add-total', 'addTotal')->middleware('perm:customer.edit');
Route::get('{uuid}/services', 'getServices')->middleware('perm:customer.view');
@@ -136,6 +137,8 @@ Route::prefix('v1')
Route::get('{uuid}/invoices', 'getInvoices')->middleware('perm:customer.view');
Route::get('{uuid}/transactions', 'getTransactions')->middleware('perm:customer.view');
Route::get('{uuid}/domains', 'getDomains')->middleware('perm:customer.view');
Route::get('{uuid}/dns-domains', 'getDnsDomains')->middleware('perm:customer.view');
Route::get('{uuid}/dns-domains/{domainId}/records', 'getDnsDomainRecords')->middleware('perm:customer.view');
Route::post('{uuid}/restore', 'restore')->middleware('perm:customer.edit');
Route::delete('{uuid}/force', 'forceDelete')->middleware('perm:customer.destroy');
});
+2
View File
@@ -30,11 +30,13 @@ Route::prefix('v1')
Route::post('login', 'login')->name('login');
Route::post('register', 'register')->name('register');
Route::post('refresh', 'refresh')->name('refresh');
Route::post('forgot-password', 'forgotPassword')->name('forgot-password');
Route::group(['middleware' => ['api.auth']], function () {
Route::get('me', 'me')->name('me');
Route::post('logout', 'logout')->name('logout');
Route::put('update', 'update')->name('update');
Route::put('change-password', 'changePassword')->name('change-password');
});
});