diff --git a/website/app/Http/Controllers/Admin/AuditLogController.php b/website/app/Http/Controllers/Admin/AuditLogController.php new file mode 100644 index 0000000..389054e --- /dev/null +++ b/website/app/Http/Controllers/Admin/AuditLogController.php @@ -0,0 +1,67 @@ +with('user:id,name,email') + ->latest(); + + // Search by user name/email, action, or resource type + if ($search = $request->input('search')) { + $query->where(function ($q) use ($search): void { + $q->where('action', 'like', "%{$search}%") + ->orWhere('resource_type', 'like', "%{$search}%") + ->orWhere('ip_address', 'like', "%{$search}%") + ->orWhereHas('user', function ($userQuery) use ($search): void { + $userQuery->where('name', 'like', "%{$search}%") + ->orWhere('email', 'like', "%{$search}%"); + }); + }); + } + + // Filter by action + if ($action = $request->input('action')) { + $query->where('action', $action); + } + + // Filter by date range + if ($dateFrom = $request->input('date_from')) { + $query->whereDate('created_at', '>=', $dateFrom); + } + + if ($dateTo = $request->input('date_to')) { + $query->whereDate('created_at', '<=', $dateTo); + } + + $auditLogs = $query->paginate(25)->withQueryString(); + + // Get distinct actions for the filter dropdown + $actions = AuditLog::query() + ->distinct() + ->orderBy('action') + ->pluck('action'); + + return Inertia::render('Admin/AuditLogs/Index', [ + 'auditLogs' => $auditLogs, + 'actions' => $actions, + 'filters' => [ + 'search' => $request->input('search', ''), + 'action' => $request->input('action', ''), + 'date_from' => $request->input('date_from', ''), + 'date_to' => $request->input('date_to', ''), + ], + ]); + } +} diff --git a/website/app/Http/Controllers/Admin/SettingsController.php b/website/app/Http/Controllers/Admin/SettingsController.php new file mode 100644 index 0000000..04bed83 --- /dev/null +++ b/website/app/Http/Controllers/Admin/SettingsController.php @@ -0,0 +1,137 @@ +> + */ + private const SETTING_DEFAULTS = [ + 'general' => [ + 'company_name' => 'EZSCALE', + 'company_email' => null, + 'support_url' => null, + 'status_page_url' => null, + ], + 'api' => [ + 'virtfusion_api_url' => null, + 'virtfusion_api_token' => null, + 'synergycp_api_url' => null, + 'synergycp_api_token' => null, + 'enhance_api_url' => null, + 'enhance_api_token' => null, + ], + 'billing' => [ + 'default_currency' => 'USD', + 'grace_period_days' => '7', + 'suspension_warning_days' => '3', + 'auto_terminate_days' => '14', + 'bandwidth_overage_rate' => '0.05', + ], + 'notifications' => [ + 'discord_webhook_url' => null, + 'slack_webhook_url' => null, + 'email_from_address' => null, + 'email_from_name' => null, + ], + ]; + + /** + * Keys that contain sensitive values and should be masked in the UI. + * + * @var array + */ + private const SENSITIVE_KEYS = [ + 'virtfusion_api_token', + 'synergycp_api_token', + 'enhance_api_token', + ]; + + public function index(): Response + { + $settings = []; + + foreach (self::SETTING_DEFAULTS as $group => $keys) { + $stored = Setting::getGroup($group); + + foreach ($keys as $key => $default) { + $value = $stored[$key] ?? $default; + $settings[$group][$key] = $value; + } + } + + // Add read-only env-based values for display (masked) + $settings['api']['stripe_publishable_key'] = $this->maskValue(config('cashier.key')); + $settings['api']['stripe_secret_key'] = $this->maskValue(config('cashier.secret')); + $settings['api']['paypal_client_id'] = $this->maskValue(config('paypal.credentials.client_id')); + $settings['api']['paypal_client_secret'] = $this->maskValue(config('paypal.credentials.client_secret')); + + // Mask sensitive stored values + foreach (self::SENSITIVE_KEYS as $key) { + foreach (self::SETTING_DEFAULTS as $group => $keys) { + if (array_key_exists($key, $keys) && ! empty($settings[$group][$key])) { + $settings[$group]["{$key}_masked"] = $this->maskValue($settings[$group][$key]); + $settings[$group]["{$key}_set"] = true; + } elseif (array_key_exists($key, $keys)) { + $settings[$group]["{$key}_masked"] = ''; + $settings[$group]["{$key}_set"] = false; + } + } + } + + return Inertia::render('Admin/Settings/Index', [ + 'settings' => $settings, + ]); + } + + public function update(UpdateSettingsRequest $request): RedirectResponse + { + $group = $request->validated('group'); + $validated = $request->validated(); + + unset($validated['group']); + + // For sensitive keys, skip if the value wasn't actually changed (empty means "keep current") + foreach (self::SENSITIVE_KEYS as $sensitiveKey) { + if (array_key_exists($sensitiveKey, $validated) && empty($validated[$sensitiveKey])) { + unset($validated[$sensitiveKey]); + } + } + + Setting::setGroup($group, $validated); + + return redirect() + ->route('admin.settings.index') + ->with('success', ucfirst($group).' settings updated successfully.'); + } + + /** + * Mask a sensitive value, showing only the last 4 characters. + */ + private function maskValue(?string $value): string + { + if (empty($value)) { + return ''; + } + + $length = strlen($value); + + if ($length <= 4) { + return str_repeat('*', $length); + } + + return str_repeat('*', $length - 4).substr($value, -4); + } +} diff --git a/website/app/Http/Requests/Admin/UpdateSettingsRequest.php b/website/app/Http/Requests/Admin/UpdateSettingsRequest.php new file mode 100644 index 0000000..4027bf2 --- /dev/null +++ b/website/app/Http/Requests/Admin/UpdateSettingsRequest.php @@ -0,0 +1,101 @@ +> */ + public function rules(): array + { + $group = $this->input('group', 'general'); + + return match ($group) { + 'general' => $this->generalRules(), + 'api' => $this->apiRules(), + 'billing' => $this->billingRules(), + 'notifications' => $this->notificationRules(), + default => ['group' => ['required', Rule::in(['general', 'api', 'billing', 'notifications'])]], + }; + } + + /** @return array> */ + private function generalRules(): array + { + return [ + 'group' => ['required', 'string'], + 'company_name' => ['nullable', 'string', 'max:255'], + 'company_email' => ['nullable', 'email', 'max:255'], + 'support_url' => ['nullable', 'url', 'max:500'], + 'status_page_url' => ['nullable', 'url', 'max:500'], + ]; + } + + /** @return array> */ + private function apiRules(): array + { + return [ + 'group' => ['required', 'string'], + 'virtfusion_api_url' => ['nullable', 'url', 'max:500'], + 'virtfusion_api_token' => ['nullable', 'string', 'max:1000'], + 'synergycp_api_url' => ['nullable', 'url', 'max:500'], + 'synergycp_api_token' => ['nullable', 'string', 'max:1000'], + 'enhance_api_url' => ['nullable', 'url', 'max:500'], + 'enhance_api_token' => ['nullable', 'string', 'max:1000'], + ]; + } + + /** @return array> */ + private function billingRules(): array + { + return [ + 'group' => ['required', 'string'], + 'default_currency' => ['nullable', 'string', 'max:3'], + 'grace_period_days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'suspension_warning_days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'auto_terminate_days' => ['nullable', 'integer', 'min:0', 'max:365'], + 'bandwidth_overage_rate' => ['nullable', 'numeric', 'min:0', 'max:999.99'], + ]; + } + + /** @return array> */ + private function notificationRules(): array + { + return [ + 'group' => ['required', 'string'], + 'discord_webhook_url' => ['nullable', 'url', 'max:500'], + 'slack_webhook_url' => ['nullable', 'url', 'max:500'], + 'email_from_address' => ['nullable', 'email', 'max:255'], + 'email_from_name' => ['nullable', 'string', 'max:255'], + ]; + } + + /** @return array */ + public function messages(): array + { + return [ + 'company_email.email' => 'Please enter a valid email address.', + 'support_url.url' => 'Please enter a valid URL.', + 'status_page_url.url' => 'Please enter a valid URL.', + 'virtfusion_api_url.url' => 'Please enter a valid URL.', + 'synergycp_api_url.url' => 'Please enter a valid URL.', + 'enhance_api_url.url' => 'Please enter a valid URL.', + 'discord_webhook_url.url' => 'Please enter a valid Discord webhook URL.', + 'slack_webhook_url.url' => 'Please enter a valid Slack webhook URL.', + 'email_from_address.email' => 'Please enter a valid email address.', + 'grace_period_days.integer' => 'Grace period must be a whole number.', + 'suspension_warning_days.integer' => 'Suspension warning days must be a whole number.', + 'auto_terminate_days.integer' => 'Auto-terminate days must be a whole number.', + 'bandwidth_overage_rate.numeric' => 'Bandwidth overage rate must be a number.', + ]; + } +} diff --git a/website/app/Models/Setting.php b/website/app/Models/Setting.php new file mode 100644 index 0000000..944549e --- /dev/null +++ b/website/app/Models/Setting.php @@ -0,0 +1,62 @@ +where('key', $key)->first(); + + return $setting?->value ?? $default; + } + + /** + * Set a setting value by key. + */ + public static function set(string $key, mixed $value, string $group = 'general'): void + { + static::query()->updateOrCreate( + ['key' => $key], + ['value' => $value, 'group' => $group], + ); + } + + /** + * Get all settings for a given group as a key-value array. + * + * @return array + */ + public static function getGroup(string $group): array + { + return static::query() + ->where('group', $group) + ->pluck('value', 'key') + ->toArray(); + } + + /** + * Set multiple settings at once for a given group. + * + * @param array $settings + */ + public static function setGroup(string $group, array $settings): void + { + foreach ($settings as $key => $value) { + static::set($key, $value, $group); + } + } +} diff --git a/website/database/migrations/2026_02_09_153959_create_settings_table.php b/website/database/migrations/2026_02_09_153959_create_settings_table.php new file mode 100644 index 0000000..c0d7a09 --- /dev/null +++ b/website/database/migrations/2026_02_09_153959_create_settings_table.php @@ -0,0 +1,30 @@ +id(); + $table->string('key')->unique(); + $table->text('value')->nullable(); + $table->string('group')->default('general'); + $table->timestamps(); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('settings'); + } +}; diff --git a/website/resources/ts/Pages/Admin/AuditLogs/Index.vue b/website/resources/ts/Pages/Admin/AuditLogs/Index.vue new file mode 100644 index 0000000..183b492 --- /dev/null +++ b/website/resources/ts/Pages/Admin/AuditLogs/Index.vue @@ -0,0 +1,372 @@ + + + diff --git a/website/resources/ts/Pages/Admin/Settings/Index.vue b/website/resources/ts/Pages/Admin/Settings/Index.vue new file mode 100644 index 0000000..65db04e --- /dev/null +++ b/website/resources/ts/Pages/Admin/Settings/Index.vue @@ -0,0 +1,567 @@ + + + diff --git a/website/resources/ts/navigation/admin.ts b/website/resources/ts/navigation/admin.ts index 9133a8b..c446a74 100644 --- a/website/resources/ts/navigation/admin.ts +++ b/website/resources/ts/navigation/admin.ts @@ -7,4 +7,6 @@ export const adminNavItems: NavItem[] = [ { title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' }, { title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' }, { title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' }, + { title: 'Audit Logs', href: '/audit-logs', icon: 'tabler-clipboard-list', matchPrefix: '/audit-logs' }, + { title: 'Settings', href: '/settings', icon: 'tabler-settings', matchPrefix: '/settings' }, ] diff --git a/website/routes/admin.php b/website/routes/admin.php index f105786..a0d05a1 100644 --- a/website/routes/admin.php +++ b/website/routes/admin.php @@ -2,12 +2,14 @@ declare(strict_types=1); +use App\Http\Controllers\Admin\AuditLogController; use App\Http\Controllers\Admin\CouponController; use App\Http\Controllers\Admin\CustomerController; use App\Http\Controllers\Admin\DashboardController; use App\Http\Controllers\Admin\InvoiceController; use App\Http\Controllers\Admin\PlanController; use App\Http\Controllers\Admin\ServiceController; +use App\Http\Controllers\Admin\SettingsController; use Illuminate\Support\Facades\Route; Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard'); @@ -41,3 +43,8 @@ Route::resource('coupons', CouponController::class)->names([ 'update' => 'admin.coupons.update', 'destroy' => 'admin.coupons.destroy', ])->except(['show']); + +Route::get('audit-logs', [AuditLogController::class, 'index'])->name('audit-logs.index'); + +Route::get('settings', [SettingsController::class, 'index'])->name('admin.settings.index'); +Route::put('settings', [SettingsController::class, 'update'])->name('admin.settings.update');