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:
Claude Dev
2026-02-09 10:45:31 -05:00
parent d9ec414264
commit 813fde30c2
9 changed files with 1345 additions and 0 deletions

View 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', ''),
],
]);
}
}

View 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);
}
}

View 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.',
];
}
}

View 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);
}
}
}

View File

@@ -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');
}
};

View 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>

View 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 &rarr; {{ billingForm.grace_period_days || 0 }} days grace period &rarr;
Warning sent &rarr; {{ billingForm.suspension_warning_days || 0 }} days &rarr;
Service suspended &rarr; {{ billingForm.auto_terminate_days || 0 }} days &rarr;
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>

View File

@@ -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' },
] ]

View File

@@ -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');