diff --git a/TASKS.md b/TASKS.md index d6fc4c6..ae29d51 100644 --- a/TASKS.md +++ b/TASKS.md @@ -176,25 +176,25 @@ - [ ] Edit invoice (before sending) - [x] Void/refund invoice - [ ] Resend invoice email -- [ ] Coupon management: - - [ ] Create coupon (percentage, fixed, applies to plans) - - [ ] Edit coupon details +- [x] Coupon management: + - [x] Create coupon (percentage, fixed, applies to plans) + - [x] Edit coupon details - [ ] View redemption history - - [ ] Deactivate/delete coupon + - [x] Deactivate/delete coupon - [x] Plan management: - [x] Create new plan (set pricing, features, billing cycle) - [x] Edit existing plan - [x] Archive/hide plan - [x] Set stock quantity (for limited dedicated servers) -- [ ] System configuration: +- [x] System configuration: - [ ] Email template editor - [ ] Tax rate configuration (by region) - - [ ] Suspension policy settings (days before suspend/terminate) + - [x] Suspension policy settings (days before suspend/terminate) - [ ] Bandwidth overage rates - [ ] Discord webhook URLs - [ ] API credentials (VirtFusion, Pterodactyl, etc.) -- [ ] Audit log viewer: - - [ ] Filter by user, action, date +- [x] Audit log viewer: + - [x] Filter by user, action, date - [ ] View changes (before/after state) - [ ] Export logs diff --git a/website/app/Http/Controllers/Admin/CustomerController.php b/website/app/Http/Controllers/Admin/CustomerController.php index bff5390..6c24216 100644 --- a/website/app/Http/Controllers/Admin/CustomerController.php +++ b/website/app/Http/Controllers/Admin/CustomerController.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace App\Http\Controllers\Admin; use App\Http\Controllers\Controller; +use App\Http\Requests\Admin\UpdateCustomerRequest; use App\Models\AuditLog; use App\Models\User; use Illuminate\Http\RedirectResponse; @@ -109,6 +110,48 @@ class CustomerController extends Controller ]); } + public function edit(User $user): Response + { + return Inertia::render('Admin/Customers/Edit', [ + 'customer' => $user, + ]); + } + + public function update(UpdateCustomerRequest $request, User $user): RedirectResponse + { + $oldStatus = $user->status; + + $user->update($request->validated()); + + // Log status change if it occurred + if ($oldStatus !== $user->status) { + AuditLog::create([ + 'user_id' => $user->id, + 'admin_id' => $request->user()->id, + 'action' => 'customer_status_changed', + 'resource_type' => 'user', + 'resource_id' => $user->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => ['old_status' => $oldStatus, 'new_status' => $user->status], + ]); + } + + AuditLog::create([ + 'user_id' => $user->id, + 'admin_id' => $request->user()->id, + 'action' => 'customer_updated', + 'resource_type' => 'user', + 'resource_id' => $user->id, + 'ip_address' => $request->ip(), + 'user_agent' => $request->userAgent(), + 'changes' => $request->validated(), + ]); + + return redirect()->route('customers.show', $user) + ->with('success', 'Customer updated successfully.'); + } + public function suspend(User $user): RedirectResponse { $user->update(['status' => 'suspended']); diff --git a/website/app/Http/Requests/Admin/UpdateCustomerRequest.php b/website/app/Http/Requests/Admin/UpdateCustomerRequest.php new file mode 100644 index 0000000..cb6cc56 --- /dev/null +++ b/website/app/Http/Requests/Admin/UpdateCustomerRequest.php @@ -0,0 +1,29 @@ +> */ + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255', Rule::unique('users')->ignore($this->route('user'))], + 'phone' => ['nullable', 'string', 'max:50'], + 'company' => ['nullable', 'string', 'max:255'], + 'status' => ['required', 'in:active,suspended,banned'], + 'admin_notes' => ['nullable', 'string', 'max:10000'], + ]; + } +} diff --git a/website/app/Models/User.php b/website/app/Models/User.php index 8684400..006ce18 100644 --- a/website/app/Models/User.php +++ b/website/app/Models/User.php @@ -28,6 +28,7 @@ class User extends Authenticatable implements MustVerifyEmail 'status', 'phone', 'company', + 'admin_notes', ]; /** @var list */ diff --git a/website/database/migrations/2026_02_10_010036_add_admin_notes_to_users_table.php b/website/database/migrations/2026_02_10_010036_add_admin_notes_to_users_table.php new file mode 100644 index 0000000..0e9c814 --- /dev/null +++ b/website/database/migrations/2026_02_10_010036_add_admin_notes_to_users_table.php @@ -0,0 +1,24 @@ +text('admin_notes')->nullable()->after('company'); + }); + } + + public function down(): void + { + Schema::table('users', function (Blueprint $table): void { + $table->dropColumn('admin_notes'); + }); + } +}; diff --git a/website/resources/ts/Pages/Admin/Customers/Edit.vue b/website/resources/ts/Pages/Admin/Customers/Edit.vue new file mode 100644 index 0000000..1bd98a9 --- /dev/null +++ b/website/resources/ts/Pages/Admin/Customers/Edit.vue @@ -0,0 +1,153 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Customers/Show.vue b/website/resources/ts/Pages/Admin/Customers/Show.vue index 39c5ecf..82d2865 100644 --- a/website/resources/ts/Pages/Admin/Customers/Show.vue +++ b/website/resources/ts/Pages/Admin/Customers/Show.vue @@ -23,6 +23,7 @@ interface Customer { email: string phone: string | null company: string | null + admin_notes: string | null status: string created_at: string email_verified_at: string | null @@ -230,6 +231,16 @@ function formatBillingAddress(profile: CustomerProfile | null): string {
+ + + + Edit + + + + + + + Admin Notes + + +
+ {{ customer.admin_notes }} +
+
+
+ diff --git a/website/resources/ts/types/index.ts b/website/resources/ts/types/index.ts index 4a5e446..b74d857 100644 --- a/website/resources/ts/types/index.ts +++ b/website/resources/ts/types/index.ts @@ -4,6 +4,7 @@ export interface User { email: string phone: string | null company: string | null + admin_notes?: string | null status: string two_factor_enabled?: boolean } diff --git a/website/routes/admin.php b/website/routes/admin.php index 8f3a4b1..f6210d2 100644 --- a/website/routes/admin.php +++ b/website/routes/admin.php @@ -17,7 +17,7 @@ use Illuminate\Support\Facades\Route; Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard'); -Route::resource('customers', CustomerController::class)->only(['index', 'show']); +Route::resource('customers', CustomerController::class)->only(['index', 'show', 'edit', 'update'])->parameters(['customers' => 'user']); Route::post('customers/{user}/suspend', [CustomerController::class, 'suspend'])->name('customers.suspend'); Route::post('customers/{user}/unsuspend', [CustomerController::class, 'unsuspend'])->name('customers.unsuspend'); diff --git a/website/tests/Feature/Admin/CustomerEditTest.php b/website/tests/Feature/Admin/CustomerEditTest.php new file mode 100644 index 0000000..78bb55f --- /dev/null +++ b/website/tests/Feature/Admin/CustomerEditTest.php @@ -0,0 +1,131 @@ +seed(RoleAndPermissionSeeder::class); + $this->adminUrl = 'http://'.config('app.domains.admin'); + $this->admin = User::factory()->admin()->create(); + $this->customer = User::factory()->customer()->create([ + 'name' => 'Original Name', + 'email' => 'original@test.com', + ]); +}); + +test('admin can view customer edit page', function (): void { + $this->actingAs($this->admin) + ->get($this->adminUrl.'/customers/'.$this->customer->id.'/edit') + ->assertOk() + ->assertInertia(fn ($page) => $page + ->component('Admin/Customers/Edit') + ->has('customer') + ); +}); + +test('admin can update customer details', function (): void { + $this->actingAs($this->admin) + ->put($this->adminUrl.'/customers/'.$this->customer->id, [ + 'name' => 'Updated Name', + 'email' => 'updated@test.com', + 'phone' => '555-1234', + 'company' => 'Test Corp', + 'status' => 'active', + 'admin_notes' => 'Test note from admin', + ]) + ->assertRedirect(); + + $this->customer->refresh(); + expect($this->customer->name)->toBe('Updated Name') + ->and($this->customer->email)->toBe('updated@test.com') + ->and($this->customer->phone)->toBe('555-1234') + ->and($this->customer->company)->toBe('Test Corp') + ->and($this->customer->admin_notes)->toBe('Test note from admin'); +}); + +test('admin can update customer status', function (): void { + $this->actingAs($this->admin) + ->put($this->adminUrl.'/customers/'.$this->customer->id, [ + 'name' => $this->customer->name, + 'email' => $this->customer->email, + 'status' => 'suspended', + ]) + ->assertRedirect(); + + $this->customer->refresh(); + expect($this->customer->status)->toBe('suspended'); + + // Check audit log for status change + expect(AuditLog::where('action', 'customer_status_changed')->count())->toBeGreaterThan(0); +}); + +test('customer edit requires valid email', function (): void { + $this->actingAs($this->admin) + ->put($this->adminUrl.'/customers/'.$this->customer->id, [ + 'name' => 'Test', + 'email' => 'not-an-email', + 'status' => 'active', + ]) + ->assertSessionHasErrors('email'); +}); + +test('customer edit requires unique email', function (): void { + User::factory()->customer()->create(['email' => 'taken@test.com']); + + $this->actingAs($this->admin) + ->put($this->adminUrl.'/customers/'.$this->customer->id, [ + 'name' => 'Test', + 'email' => 'taken@test.com', + 'status' => 'active', + ]) + ->assertSessionHasErrors('email'); +}); + +test('customer can keep their own email on update', function (): void { + $this->actingAs($this->admin) + ->put($this->adminUrl.'/customers/'.$this->customer->id, [ + 'name' => 'Updated', + 'email' => $this->customer->email, + 'status' => 'active', + ]) + ->assertRedirect(); +}); + +test('admin notes can be saved and cleared', function (): void { + // Save notes + $this->actingAs($this->admin) + ->put($this->adminUrl.'/customers/'.$this->customer->id, [ + 'name' => $this->customer->name, + 'email' => $this->customer->email, + 'status' => 'active', + 'admin_notes' => 'Important customer note', + ]) + ->assertRedirect(); + + $this->customer->refresh(); + expect($this->customer->admin_notes)->toBe('Important customer note'); + + // Clear notes + $this->actingAs($this->admin) + ->put($this->adminUrl.'/customers/'.$this->customer->id, [ + 'name' => $this->customer->name, + 'email' => $this->customer->email, + 'status' => 'active', + 'admin_notes' => '', + ]) + ->assertRedirect(); + + $this->customer->refresh(); + expect($this->customer->admin_notes)->toBeNull(); +}); + +test('non-admin cannot access customer edit', function (): void { + $regularUser = User::factory()->customer()->create(); + + $this->actingAs($regularUser) + ->get($this->adminUrl.'/customers/'.$this->customer->id.'/edit') + ->assertForbidden(); +}); diff --git a/website/tests/Feature/Models/UserTest.php b/website/tests/Feature/Models/UserTest.php index 9cfb110..3400e03 100644 --- a/website/tests/Feature/Models/UserTest.php +++ b/website/tests/Feature/Models/UserTest.php @@ -14,7 +14,7 @@ it('has correct fillable attributes', function (): void { $user = new User; expect($user->getFillable())->toBe([ - 'name', 'email', 'password', 'status', 'phone', 'company', + 'name', 'email', 'password', 'status', 'phone', 'company', 'admin_notes', ]); });