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:
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>
|
||||
Reference in New Issue
Block a user