Add admin audit log viewer and system settings
Phase 5 (Admin Panel): - Audit log viewer: searchable with action/date filters, expandable rows showing JSON changes, color-coded action chips, user avatars - System settings: tabbed page (General, API Credentials, Billing, Notifications) with masked sensitive values, per-group save - Settings model with get/set/getGroup/setGroup helpers - Settings migration for key-value store with groups - 52 tests passing, build clean Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
67
website/app/Http/Controllers/Admin/AuditLogController.php
Normal file
67
website/app/Http/Controllers/Admin/AuditLogController.php
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Models\AuditLog;
|
||||||
|
use Illuminate\Http\Request;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class AuditLogController extends Controller
|
||||||
|
{
|
||||||
|
public function index(Request $request): Response
|
||||||
|
{
|
||||||
|
$query = AuditLog::query()
|
||||||
|
->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', ''),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
137
website/app/Http/Controllers/Admin/SettingsController.php
Normal file
137
website/app/Http/Controllers/Admin/SettingsController.php
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Controllers\Admin;
|
||||||
|
|
||||||
|
use App\Http\Controllers\Controller;
|
||||||
|
use App\Http\Requests\Admin\UpdateSettingsRequest;
|
||||||
|
use App\Models\Setting;
|
||||||
|
use Illuminate\Http\RedirectResponse;
|
||||||
|
use Inertia\Inertia;
|
||||||
|
use Inertia\Response;
|
||||||
|
|
||||||
|
class SettingsController extends Controller
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* The setting keys for each group, with their defaults.
|
||||||
|
*
|
||||||
|
* @var array<string, array<string, string|null>>
|
||||||
|
*/
|
||||||
|
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<int, string>
|
||||||
|
*/
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
101
website/app/Http/Requests/Admin/UpdateSettingsRequest.php
Normal file
101
website/app/Http/Requests/Admin/UpdateSettingsRequest.php
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Http\Requests\Admin;
|
||||||
|
|
||||||
|
use Illuminate\Foundation\Http\FormRequest;
|
||||||
|
use Illuminate\Validation\Rule;
|
||||||
|
|
||||||
|
class UpdateSettingsRequest extends FormRequest
|
||||||
|
{
|
||||||
|
public function authorize(): bool
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<int, mixed>> */
|
||||||
|
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<string, array<int, mixed>> */
|
||||||
|
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<string, array<int, mixed>> */
|
||||||
|
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<string, array<int, mixed>> */
|
||||||
|
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<string, array<int, mixed>> */
|
||||||
|
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<string, string> */
|
||||||
|
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.',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
62
website/app/Models/Setting.php
Normal file
62
website/app/Models/Setting.php
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace App\Models;
|
||||||
|
|
||||||
|
use Illuminate\Database\Eloquent\Model;
|
||||||
|
|
||||||
|
class Setting extends Model
|
||||||
|
{
|
||||||
|
protected $fillable = [
|
||||||
|
'key',
|
||||||
|
'value',
|
||||||
|
'group',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a setting value by key.
|
||||||
|
*/
|
||||||
|
public static function get(string $key, mixed $default = null): mixed
|
||||||
|
{
|
||||||
|
$setting = static::query()->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<string, string|null>
|
||||||
|
*/
|
||||||
|
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<string, string|null> $settings
|
||||||
|
*/
|
||||||
|
public static function setGroup(string $group, array $settings): void
|
||||||
|
{
|
||||||
|
foreach ($settings as $key => $value) {
|
||||||
|
static::set($key, $value, $group);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Database\Migrations\Migration;
|
||||||
|
use Illuminate\Database\Schema\Blueprint;
|
||||||
|
use Illuminate\Support\Facades\Schema;
|
||||||
|
|
||||||
|
return new class extends Migration
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Run the migrations.
|
||||||
|
*/
|
||||||
|
public function up(): void
|
||||||
|
{
|
||||||
|
Schema::create('settings', function (Blueprint $table) {
|
||||||
|
$table->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');
|
||||||
|
}
|
||||||
|
};
|
||||||
372
website/resources/ts/Pages/Admin/AuditLogs/Index.vue
Normal file
372
website/resources/ts/Pages/Admin/AuditLogs/Index.vue
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { router } from '@inertiajs/vue3'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
|
import type { PaginatedResponse } from '@/types'
|
||||||
|
|
||||||
|
interface AuditLogUser {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
email: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuditLog {
|
||||||
|
id: number
|
||||||
|
user_id: number | null
|
||||||
|
admin_id: number | null
|
||||||
|
action: string
|
||||||
|
resource_type: string | null
|
||||||
|
resource_id: number | null
|
||||||
|
ip_address: string | null
|
||||||
|
user_agent: string | null
|
||||||
|
changes: Record<string, unknown> | null
|
||||||
|
created_at: string
|
||||||
|
user: AuditLogUser | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Filters {
|
||||||
|
search: string
|
||||||
|
action: string
|
||||||
|
date_from: string
|
||||||
|
date_to: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
auditLogs: PaginatedResponse<AuditLog>
|
||||||
|
actions: string[]
|
||||||
|
filters: Filters
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const search = ref<string>(props.filters.search)
|
||||||
|
const actionFilter = ref<string>(props.filters.action)
|
||||||
|
const dateFrom = ref<string>(props.filters.date_from)
|
||||||
|
const dateTo = ref<string>(props.filters.date_to)
|
||||||
|
const expandedRows = ref<Set<number>>(new Set())
|
||||||
|
|
||||||
|
let searchTimeout: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
watch(search, (value: string) => {
|
||||||
|
if (searchTimeout) {
|
||||||
|
clearTimeout(searchTimeout)
|
||||||
|
}
|
||||||
|
searchTimeout = setTimeout(() => {
|
||||||
|
applyFilters({ search: value })
|
||||||
|
}, 400)
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(actionFilter, (value: string) => {
|
||||||
|
applyFilters({ action: value })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(dateFrom, (value: string) => {
|
||||||
|
applyFilters({ date_from: value })
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(dateTo, (value: string) => {
|
||||||
|
applyFilters({ date_to: value })
|
||||||
|
})
|
||||||
|
|
||||||
|
function applyFilters(overrides: Partial<Filters> = {}): void {
|
||||||
|
router.get('/audit-logs', {
|
||||||
|
search: overrides.search ?? search.value,
|
||||||
|
action: overrides.action ?? actionFilter.value,
|
||||||
|
date_from: overrides.date_from ?? dateFrom.value,
|
||||||
|
date_to: overrides.date_to ?? dateTo.value,
|
||||||
|
}, {
|
||||||
|
preserveState: true,
|
||||||
|
replace: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleRow(id: number): void {
|
||||||
|
if (expandedRows.value.has(id)) {
|
||||||
|
expandedRows.value.delete(id)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
expandedRows.value.add(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpanded(id: number): boolean {
|
||||||
|
return expandedRows.value.has(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActionColor(action: string): string {
|
||||||
|
if (action.startsWith('create') || action === 'register') {
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
if (action.startsWith('update') || action.startsWith('edit')) {
|
||||||
|
return 'info'
|
||||||
|
}
|
||||||
|
if (action.startsWith('delete') || action.startsWith('terminate') || action.startsWith('destroy')) {
|
||||||
|
return 'error'
|
||||||
|
}
|
||||||
|
if (action.startsWith('login') || action === 'login') {
|
||||||
|
return 'primary'
|
||||||
|
}
|
||||||
|
if (action.startsWith('suspend') || action.startsWith('unsuspend')) {
|
||||||
|
return 'warning'
|
||||||
|
}
|
||||||
|
return 'secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveActionIcon(action: string): string {
|
||||||
|
if (action.startsWith('create') || action === 'register') {
|
||||||
|
return 'tabler-plus'
|
||||||
|
}
|
||||||
|
if (action.startsWith('update') || action.startsWith('edit')) {
|
||||||
|
return 'tabler-pencil'
|
||||||
|
}
|
||||||
|
if (action.startsWith('delete') || action.startsWith('terminate') || action.startsWith('destroy')) {
|
||||||
|
return 'tabler-trash'
|
||||||
|
}
|
||||||
|
if (action.startsWith('login') || action === 'login') {
|
||||||
|
return 'tabler-login'
|
||||||
|
}
|
||||||
|
if (action.startsWith('suspend')) {
|
||||||
|
return 'tabler-ban'
|
||||||
|
}
|
||||||
|
if (action.startsWith('unsuspend')) {
|
||||||
|
return 'tabler-circle-check'
|
||||||
|
}
|
||||||
|
return 'tabler-activity'
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAction(action: string): string {
|
||||||
|
return action
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c: string) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatResourceType(type: string | null): string {
|
||||||
|
if (!type) {
|
||||||
|
return '-'
|
||||||
|
}
|
||||||
|
return type
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/\b\w/g, (c: string) => c.toUpperCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
return date.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatJson(changes: Record<string, unknown> | null): string {
|
||||||
|
if (!changes) {
|
||||||
|
return '{}'
|
||||||
|
}
|
||||||
|
return JSON.stringify(changes, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasChanges(log: AuditLog): boolean {
|
||||||
|
return log.changes !== null && Object.keys(log.changes).length > 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilters(): void {
|
||||||
|
search.value = ''
|
||||||
|
actionFilter.value = ''
|
||||||
|
dateFrom.value = ''
|
||||||
|
dateTo.value = ''
|
||||||
|
applyFilters({ search: '', action: '', date_from: '', date_to: '' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasActiveFilters = computed<boolean>(() => {
|
||||||
|
return search.value !== '' || actionFilter.value !== '' || dateFrom.value !== '' || dateTo.value !== ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Page Header -->
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-bold">
|
||||||
|
Audit Logs
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Track all system activity and administrative actions
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VChip color="primary" variant="tonal" size="small">
|
||||||
|
{{ auditLogs.total }} entries
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters -->
|
||||||
|
<VCard class="mb-6">
|
||||||
|
<VCardText>
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<VTextField
|
||||||
|
v-model="search"
|
||||||
|
prepend-inner-icon="tabler-search"
|
||||||
|
placeholder="Search by user, action, IP..."
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
clearable
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="2">
|
||||||
|
<VSelect
|
||||||
|
v-model="actionFilter"
|
||||||
|
:items="[{ title: 'All Actions', value: '' }, ...actions.map(a => ({ title: formatAction(a), value: a }))]"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="2">
|
||||||
|
<VTextField
|
||||||
|
v-model="dateFrom"
|
||||||
|
type="date"
|
||||||
|
label="From"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="2">
|
||||||
|
<VTextField
|
||||||
|
v-model="dateTo"
|
||||||
|
type="date"
|
||||||
|
label="To"
|
||||||
|
variant="outlined"
|
||||||
|
density="compact"
|
||||||
|
hide-details
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="2" class="d-flex align-center">
|
||||||
|
<VBtn
|
||||||
|
v-if="hasActiveFilters"
|
||||||
|
variant="text"
|
||||||
|
color="secondary"
|
||||||
|
size="small"
|
||||||
|
@click="clearFilters"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-x" start />
|
||||||
|
Clear
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
|
||||||
|
<!-- Audit Logs Table -->
|
||||||
|
<VCard>
|
||||||
|
<VCardText v-if="auditLogs.data.length === 0" class="text-center py-12">
|
||||||
|
<VIcon icon="tabler-clipboard-off" size="48" color="disabled" class="mb-2" />
|
||||||
|
<div class="text-medium-emphasis">
|
||||||
|
No audit log entries found.
|
||||||
|
</div>
|
||||||
|
</VCardText>
|
||||||
|
|
||||||
|
<VTable v-else density="comfortable" hover>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th style="width: 40px;" />
|
||||||
|
<th>Timestamp</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>Action</th>
|
||||||
|
<th>Resource Type</th>
|
||||||
|
<th>Resource ID</th>
|
||||||
|
<th>IP Address</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<template v-for="log in auditLogs.data" :key="log.id">
|
||||||
|
<tr
|
||||||
|
:class="{ 'cursor-pointer': hasChanges(log) }"
|
||||||
|
@click="hasChanges(log) ? toggleRow(log.id) : undefined"
|
||||||
|
>
|
||||||
|
<td>
|
||||||
|
<VBtn
|
||||||
|
v-if="hasChanges(log)"
|
||||||
|
variant="text"
|
||||||
|
size="x-small"
|
||||||
|
:icon="isExpanded(log.id) ? 'tabler-chevron-down' : 'tabler-chevron-right'"
|
||||||
|
@click.stop="toggleRow(log.id)"
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ formatDateTime(log.created_at) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div v-if="log.user" class="d-flex align-center gap-2">
|
||||||
|
<VAvatar color="primary" variant="tonal" size="30">
|
||||||
|
<span class="text-caption font-weight-medium">
|
||||||
|
{{ log.user.name.charAt(0).toUpperCase() }}
|
||||||
|
</span>
|
||||||
|
</VAvatar>
|
||||||
|
<div>
|
||||||
|
<div class="text-body-2 font-weight-medium">
|
||||||
|
{{ log.user.name }}
|
||||||
|
</div>
|
||||||
|
<div class="text-caption text-medium-emphasis">
|
||||||
|
{{ log.user.email }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<VChip v-else size="small" variant="tonal" color="secondary">
|
||||||
|
System
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<VChip
|
||||||
|
:color="resolveActionColor(log.action)"
|
||||||
|
size="small"
|
||||||
|
variant="tonal"
|
||||||
|
>
|
||||||
|
<VIcon :icon="resolveActionIcon(log.action)" start size="14" />
|
||||||
|
{{ formatAction(log.action) }}
|
||||||
|
</VChip>
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ formatResourceType(log.resource_type) }}
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2">
|
||||||
|
{{ log.resource_id ?? '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="text-body-2 text-medium-emphasis">
|
||||||
|
{{ log.ip_address ?? '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<!-- Expanded row: changes JSON -->
|
||||||
|
<tr v-if="isExpanded(log.id) && hasChanges(log)">
|
||||||
|
<td colspan="7" class="pa-0">
|
||||||
|
<div class="pa-4 bg-surface-variant">
|
||||||
|
<div class="text-caption font-weight-semibold mb-2">
|
||||||
|
Changes
|
||||||
|
</div>
|
||||||
|
<pre class="text-caption" style="white-space: pre-wrap; word-break: break-all;">{{ formatJson(log.changes) }}</pre>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</tbody>
|
||||||
|
</VTable>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<VCardText v-if="auditLogs.last_page > 1" class="d-flex align-center justify-center pt-4">
|
||||||
|
<VPagination
|
||||||
|
:model-value="Math.ceil((auditLogs.from || 1) / 25)"
|
||||||
|
:length="auditLogs.last_page"
|
||||||
|
:total-visible="7"
|
||||||
|
rounded
|
||||||
|
@update:model-value="(page: number) => router.get('/audit-logs', { search: search, action: actionFilter, date_from: dateFrom, date_to: dateTo, page }, { preserveState: true })"
|
||||||
|
/>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
567
website/resources/ts/Pages/Admin/Settings/Index.vue
Normal file
567
website/resources/ts/Pages/Admin/Settings/Index.vue
Normal file
@@ -0,0 +1,567 @@
|
|||||||
|
<script lang="ts" setup>
|
||||||
|
import { useForm } from '@inertiajs/vue3'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import AdminLayout from '@/Layouts/AdminLayout.vue'
|
||||||
|
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
|
||||||
|
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
|
||||||
|
|
||||||
|
interface SettingsGroup {
|
||||||
|
[key: string]: string | boolean | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
settings: {
|
||||||
|
general: SettingsGroup
|
||||||
|
api: SettingsGroup
|
||||||
|
billing: SettingsGroup
|
||||||
|
notifications: SettingsGroup
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({ layout: AdminLayout })
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const activeTab = ref<string>('general')
|
||||||
|
|
||||||
|
// General settings form
|
||||||
|
const generalForm = useForm({
|
||||||
|
group: 'general',
|
||||||
|
company_name: (props.settings.general.company_name as string) ?? '',
|
||||||
|
company_email: (props.settings.general.company_email as string) ?? '',
|
||||||
|
support_url: (props.settings.general.support_url as string) ?? '',
|
||||||
|
status_page_url: (props.settings.general.status_page_url as string) ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// API credentials form
|
||||||
|
const apiForm = useForm({
|
||||||
|
group: 'api',
|
||||||
|
virtfusion_api_url: (props.settings.api.virtfusion_api_url as string) ?? '',
|
||||||
|
virtfusion_api_token: '',
|
||||||
|
synergycp_api_url: (props.settings.api.synergycp_api_url as string) ?? '',
|
||||||
|
synergycp_api_token: '',
|
||||||
|
enhance_api_url: (props.settings.api.enhance_api_url as string) ?? '',
|
||||||
|
enhance_api_token: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Billing settings form
|
||||||
|
const billingForm = useForm({
|
||||||
|
group: 'billing',
|
||||||
|
default_currency: (props.settings.billing.default_currency as string) ?? 'USD',
|
||||||
|
grace_period_days: (props.settings.billing.grace_period_days as string) ?? '7',
|
||||||
|
suspension_warning_days: (props.settings.billing.suspension_warning_days as string) ?? '3',
|
||||||
|
auto_terminate_days: (props.settings.billing.auto_terminate_days as string) ?? '14',
|
||||||
|
bandwidth_overage_rate: (props.settings.billing.bandwidth_overage_rate as string) ?? '0.05',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Notifications settings form
|
||||||
|
const notificationsForm = useForm({
|
||||||
|
group: 'notifications',
|
||||||
|
discord_webhook_url: (props.settings.notifications.discord_webhook_url as string) ?? '',
|
||||||
|
slack_webhook_url: (props.settings.notifications.slack_webhook_url as string) ?? '',
|
||||||
|
email_from_address: (props.settings.notifications.email_from_address as string) ?? '',
|
||||||
|
email_from_name: (props.settings.notifications.email_from_name as string) ?? '',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Visibility toggles for sensitive API fields
|
||||||
|
const showVirtfusionToken = ref<boolean>(false)
|
||||||
|
const showSynergycpToken = ref<boolean>(false)
|
||||||
|
const showEnhanceToken = ref<boolean>(false)
|
||||||
|
|
||||||
|
const currencyOptions = [
|
||||||
|
{ title: 'USD - US Dollar', value: 'USD' },
|
||||||
|
{ title: 'EUR - Euro', value: 'EUR' },
|
||||||
|
{ title: 'GBP - British Pound', value: 'GBP' },
|
||||||
|
{ title: 'CAD - Canadian Dollar', value: 'CAD' },
|
||||||
|
{ title: 'AUD - Australian Dollar', value: 'AUD' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ value: 'general', title: 'General', icon: 'tabler-building' },
|
||||||
|
{ value: 'api', title: 'API Credentials', icon: 'tabler-key' },
|
||||||
|
{ value: 'billing', title: 'Billing', icon: 'tabler-credit-card' },
|
||||||
|
{ value: 'notifications', title: 'Notifications', icon: 'tabler-bell' },
|
||||||
|
]
|
||||||
|
|
||||||
|
function submitGeneral(): void {
|
||||||
|
generalForm.put('/settings', {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitApi(): void {
|
||||||
|
apiForm.put('/settings', {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitBilling(): void {
|
||||||
|
billingForm.put('/settings', {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitNotifications(): void {
|
||||||
|
notificationsForm.put('/settings', {
|
||||||
|
preserveScroll: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<div class="d-flex align-center justify-space-between mb-6">
|
||||||
|
<div>
|
||||||
|
<div class="text-h4 font-weight-bold">
|
||||||
|
System Settings
|
||||||
|
</div>
|
||||||
|
<div class="text-body-2 text-medium-emphasis">
|
||||||
|
Configure your EZSCALE platform settings
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<VCard>
|
||||||
|
<VTabs
|
||||||
|
v-model="activeTab"
|
||||||
|
class="v-tabs-pill"
|
||||||
|
>
|
||||||
|
<VTab
|
||||||
|
v-for="tab in tabItems"
|
||||||
|
:key="tab.value"
|
||||||
|
:value="tab.value"
|
||||||
|
>
|
||||||
|
<VIcon :icon="tab.icon" start />
|
||||||
|
{{ tab.title }}
|
||||||
|
</VTab>
|
||||||
|
</VTabs>
|
||||||
|
|
||||||
|
<VDivider />
|
||||||
|
|
||||||
|
<VCardText>
|
||||||
|
<VTabsWindow v-model="activeTab">
|
||||||
|
<!-- General Tab -->
|
||||||
|
<VTabsWindowItem value="general">
|
||||||
|
<form @submit.prevent="submitGeneral">
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="generalForm.company_name"
|
||||||
|
label="Company Name"
|
||||||
|
placeholder="EZSCALE"
|
||||||
|
:error-messages="generalForm.errors.company_name"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="generalForm.company_email"
|
||||||
|
label="Company Email"
|
||||||
|
type="email"
|
||||||
|
placeholder="support@ezscale.cloud"
|
||||||
|
:error-messages="generalForm.errors.company_email"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="generalForm.support_url"
|
||||||
|
label="Support URL"
|
||||||
|
placeholder="https://support.ezscale.cloud"
|
||||||
|
:error-messages="generalForm.errors.support_url"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="generalForm.status_page_url"
|
||||||
|
label="Status Page URL"
|
||||||
|
placeholder="https://status.ezscale.cloud"
|
||||||
|
:error-messages="generalForm.errors.status_page_url"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="generalForm.processing"
|
||||||
|
:disabled="generalForm.processing"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-device-floppy" start />
|
||||||
|
Save General Settings
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</form>
|
||||||
|
</VTabsWindowItem>
|
||||||
|
|
||||||
|
<!-- API Credentials Tab -->
|
||||||
|
<VTabsWindowItem value="api">
|
||||||
|
<form @submit.prevent="submitApi">
|
||||||
|
<!-- VirtFusion -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<VIcon icon="tabler-server" start />
|
||||||
|
VirtFusion (VPS)
|
||||||
|
</div>
|
||||||
|
<VRow class="mb-4">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="apiForm.virtfusion_api_url"
|
||||||
|
label="API URL"
|
||||||
|
placeholder="https://vps.ezscale.cloud/api/v1"
|
||||||
|
:error-messages="apiForm.errors.virtfusion_api_url"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="apiForm.virtfusion_api_token"
|
||||||
|
label="API Token"
|
||||||
|
:type="showVirtfusionToken ? 'text' : 'password'"
|
||||||
|
:placeholder="props.settings.api.virtfusion_api_token_set ? '******** (token is set, leave blank to keep)' : 'Enter API token'"
|
||||||
|
:error-messages="apiForm.errors.virtfusion_api_token"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<VIcon
|
||||||
|
:icon="showVirtfusionToken ? 'tabler-eye-off' : 'tabler-eye'"
|
||||||
|
style="cursor: pointer;"
|
||||||
|
@click="showVirtfusionToken = !showVirtfusionToken"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<VDivider class="mb-4" />
|
||||||
|
|
||||||
|
<!-- SynergyCP -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<VIcon icon="tabler-server-2" start />
|
||||||
|
SynergyCP (Dedicated)
|
||||||
|
</div>
|
||||||
|
<VRow class="mb-4">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="apiForm.synergycp_api_url"
|
||||||
|
label="API URL"
|
||||||
|
placeholder="https://dedicated.ezscale.cloud/api"
|
||||||
|
:error-messages="apiForm.errors.synergycp_api_url"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="apiForm.synergycp_api_token"
|
||||||
|
label="API Token"
|
||||||
|
:type="showSynergycpToken ? 'text' : 'password'"
|
||||||
|
:placeholder="props.settings.api.synergycp_api_token_set ? '******** (token is set, leave blank to keep)' : 'Enter API token'"
|
||||||
|
:error-messages="apiForm.errors.synergycp_api_token"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<VIcon
|
||||||
|
:icon="showSynergycpToken ? 'tabler-eye-off' : 'tabler-eye'"
|
||||||
|
style="cursor: pointer;"
|
||||||
|
@click="showSynergycpToken = !showSynergycpToken"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<VDivider class="mb-4" />
|
||||||
|
|
||||||
|
<!-- Enhance -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<VIcon icon="tabler-world" start />
|
||||||
|
Enhance (Web Hosting)
|
||||||
|
</div>
|
||||||
|
<VRow class="mb-4">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="apiForm.enhance_api_url"
|
||||||
|
label="API URL"
|
||||||
|
placeholder="https://hosting.ezscale.cloud/api"
|
||||||
|
:error-messages="apiForm.errors.enhance_api_url"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="apiForm.enhance_api_token"
|
||||||
|
label="API Token"
|
||||||
|
:type="showEnhanceToken ? 'text' : 'password'"
|
||||||
|
:placeholder="props.settings.api.enhance_api_token_set ? '******** (token is set, leave blank to keep)' : 'Enter API token'"
|
||||||
|
:error-messages="apiForm.errors.enhance_api_token"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<VIcon
|
||||||
|
:icon="showEnhanceToken ? 'tabler-eye-off' : 'tabler-eye'"
|
||||||
|
style="cursor: pointer;"
|
||||||
|
@click="showEnhanceToken = !showEnhanceToken"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<VDivider class="mb-4" />
|
||||||
|
|
||||||
|
<!-- Stripe (read-only) -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<VIcon icon="tabler-brand-stripe" start />
|
||||||
|
Stripe
|
||||||
|
<VChip size="x-small" color="info" variant="tonal" class="ms-2">
|
||||||
|
Read-only (.env)
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<VRow class="mb-4">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
:model-value="(props.settings.api.stripe_publishable_key as string) || 'Not configured'"
|
||||||
|
label="Publishable Key"
|
||||||
|
readonly
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
:model-value="(props.settings.api.stripe_secret_key as string) || 'Not configured'"
|
||||||
|
label="Secret Key"
|
||||||
|
readonly
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<VDivider class="mb-4" />
|
||||||
|
|
||||||
|
<!-- PayPal (read-only) -->
|
||||||
|
<div class="text-h6 mb-3">
|
||||||
|
<VIcon icon="tabler-brand-paypal" start />
|
||||||
|
PayPal
|
||||||
|
<VChip size="x-small" color="info" variant="tonal" class="ms-2">
|
||||||
|
Read-only (.env)
|
||||||
|
</VChip>
|
||||||
|
</div>
|
||||||
|
<VRow class="mb-6">
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
:model-value="(props.settings.api.paypal_client_id as string) || 'Not configured'"
|
||||||
|
label="Client ID"
|
||||||
|
readonly
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
:model-value="(props.settings.api.paypal_client_secret as string) || 'Not configured'"
|
||||||
|
label="Client Secret"
|
||||||
|
readonly
|
||||||
|
disabled
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="apiForm.processing"
|
||||||
|
:disabled="apiForm.processing"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-device-floppy" start />
|
||||||
|
Save API Credentials
|
||||||
|
</VBtn>
|
||||||
|
</form>
|
||||||
|
</VTabsWindowItem>
|
||||||
|
|
||||||
|
<!-- Billing Tab -->
|
||||||
|
<VTabsWindowItem value="billing">
|
||||||
|
<form @submit.prevent="submitBilling">
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppSelect
|
||||||
|
v-model="billingForm.default_currency"
|
||||||
|
label="Default Currency"
|
||||||
|
:items="currencyOptions"
|
||||||
|
:error-messages="billingForm.errors.default_currency"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.bandwidth_overage_rate"
|
||||||
|
label="Bandwidth Overage Rate ($/GB)"
|
||||||
|
type="number"
|
||||||
|
step="0.01"
|
||||||
|
min="0"
|
||||||
|
placeholder="0.05"
|
||||||
|
:error-messages="billingForm.errors.bandwidth_overage_rate"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.grace_period_days"
|
||||||
|
label="Grace Period (days)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="365"
|
||||||
|
placeholder="7"
|
||||||
|
:error-messages="billingForm.errors.grace_period_days"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<VTooltip location="top">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<VIcon
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
icon="tabler-info-circle"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
Days after invoice due date before suspension warning is sent
|
||||||
|
</VTooltip>
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.suspension_warning_days"
|
||||||
|
label="Suspension Warning (days)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="365"
|
||||||
|
placeholder="3"
|
||||||
|
:error-messages="billingForm.errors.suspension_warning_days"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<VTooltip location="top">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<VIcon
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
icon="tabler-info-circle"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
Days after warning before service is suspended
|
||||||
|
</VTooltip>
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="4">
|
||||||
|
<AppTextField
|
||||||
|
v-model="billingForm.auto_terminate_days"
|
||||||
|
label="Auto-Terminate (days)"
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
max="365"
|
||||||
|
placeholder="14"
|
||||||
|
:error-messages="billingForm.errors.auto_terminate_days"
|
||||||
|
>
|
||||||
|
<template #append-inner>
|
||||||
|
<VTooltip location="top">
|
||||||
|
<template #activator="{ props: tooltipProps }">
|
||||||
|
<VIcon
|
||||||
|
v-bind="tooltipProps"
|
||||||
|
icon="tabler-info-circle"
|
||||||
|
size="18"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
Days after suspension before service is automatically terminated
|
||||||
|
</VTooltip>
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<VAlert type="info" variant="tonal" class="mb-4">
|
||||||
|
<strong>Dunning timeline:</strong>
|
||||||
|
Invoice overdue → {{ billingForm.grace_period_days || 0 }} days grace period →
|
||||||
|
Warning sent → {{ billingForm.suspension_warning_days || 0 }} days →
|
||||||
|
Service suspended → {{ billingForm.auto_terminate_days || 0 }} days →
|
||||||
|
Service terminated
|
||||||
|
</VAlert>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="billingForm.processing"
|
||||||
|
:disabled="billingForm.processing"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-device-floppy" start />
|
||||||
|
Save Billing Settings
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</form>
|
||||||
|
</VTabsWindowItem>
|
||||||
|
|
||||||
|
<!-- Notifications Tab -->
|
||||||
|
<VTabsWindowItem value="notifications">
|
||||||
|
<form @submit.prevent="submitNotifications">
|
||||||
|
<VRow>
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="notificationsForm.discord_webhook_url"
|
||||||
|
label="Discord Webhook URL"
|
||||||
|
placeholder="https://discord.com/api/webhooks/..."
|
||||||
|
:error-messages="notificationsForm.errors.discord_webhook_url"
|
||||||
|
>
|
||||||
|
<template #prepend-inner>
|
||||||
|
<VIcon icon="tabler-brand-discord" />
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="notificationsForm.slack_webhook_url"
|
||||||
|
label="Slack Webhook URL (Optional)"
|
||||||
|
placeholder="https://hooks.slack.com/services/..."
|
||||||
|
:error-messages="notificationsForm.errors.slack_webhook_url"
|
||||||
|
>
|
||||||
|
<template #prepend-inner>
|
||||||
|
<VIcon icon="tabler-brand-slack" />
|
||||||
|
</template>
|
||||||
|
</AppTextField>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="notificationsForm.email_from_address"
|
||||||
|
label="Email From Address"
|
||||||
|
type="email"
|
||||||
|
placeholder="noreply@ezscale.cloud"
|
||||||
|
:error-messages="notificationsForm.errors.email_from_address"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12" md="6">
|
||||||
|
<AppTextField
|
||||||
|
v-model="notificationsForm.email_from_name"
|
||||||
|
label="Email From Name"
|
||||||
|
placeholder="EZSCALE"
|
||||||
|
:error-messages="notificationsForm.errors.email_from_name"
|
||||||
|
/>
|
||||||
|
</VCol>
|
||||||
|
|
||||||
|
<VCol cols="12">
|
||||||
|
<VBtn
|
||||||
|
type="submit"
|
||||||
|
color="primary"
|
||||||
|
:loading="notificationsForm.processing"
|
||||||
|
:disabled="notificationsForm.processing"
|
||||||
|
>
|
||||||
|
<VIcon icon="tabler-device-floppy" start />
|
||||||
|
Save Notification Settings
|
||||||
|
</VBtn>
|
||||||
|
</VCol>
|
||||||
|
</VRow>
|
||||||
|
</form>
|
||||||
|
</VTabsWindowItem>
|
||||||
|
</VTabsWindow>
|
||||||
|
</VCardText>
|
||||||
|
</VCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -7,4 +7,6 @@ export const adminNavItems: NavItem[] = [
|
|||||||
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
|
{ title: 'Services', href: '/services', icon: 'tabler-server', matchPrefix: '/services' },
|
||||||
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
{ title: 'Invoices', href: '/invoices', icon: 'tabler-file-invoice', matchPrefix: '/invoices' },
|
||||||
{ title: 'Coupons', href: '/coupons', icon: 'tabler-discount-2', matchPrefix: '/coupons' },
|
{ 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' },
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,12 +2,14 @@
|
|||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use App\Http\Controllers\Admin\AuditLogController;
|
||||||
use App\Http\Controllers\Admin\CouponController;
|
use App\Http\Controllers\Admin\CouponController;
|
||||||
use App\Http\Controllers\Admin\CustomerController;
|
use App\Http\Controllers\Admin\CustomerController;
|
||||||
use App\Http\Controllers\Admin\DashboardController;
|
use App\Http\Controllers\Admin\DashboardController;
|
||||||
use App\Http\Controllers\Admin\InvoiceController;
|
use App\Http\Controllers\Admin\InvoiceController;
|
||||||
use App\Http\Controllers\Admin\PlanController;
|
use App\Http\Controllers\Admin\PlanController;
|
||||||
use App\Http\Controllers\Admin\ServiceController;
|
use App\Http\Controllers\Admin\ServiceController;
|
||||||
|
use App\Http\Controllers\Admin\SettingsController;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
|
|
||||||
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
|
Route::get('/dashboard', [DashboardController::class, 'index'])->name('admin.dashboard');
|
||||||
@@ -41,3 +43,8 @@ Route::resource('coupons', CouponController::class)->names([
|
|||||||
'update' => 'admin.coupons.update',
|
'update' => 'admin.coupons.update',
|
||||||
'destroy' => 'admin.coupons.destroy',
|
'destroy' => 'admin.coupons.destroy',
|
||||||
])->except(['show']);
|
])->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');
|
||||||
|
|||||||
Reference in New Issue
Block a user