Part A: Fix duplicate Service creation on provisioning retry - All 4 provisioning services use Service::firstOrCreate() keyed on subscription_id+service_type to prevent duplicates on queue retries - HandleSubscriptionCreated sends notification before provisioning, no longer re-throws on failure - RetryProvisioningCommand simplified to reuse existing Service records Part B: Plans/Pricing page complete redesign - Service type tabs (VPS, Dedicated, Web Hosting, MySQL) - Billing cycle segmented toggle (monthly/quarterly/semi-annual/annual) - Feature icons per service type, Popular/Best Value badges - Stock indicators, effective monthly price calculations Part C: Admin service soft-delete/archive - Service model uses SoftDeletes trait - Admin can archive and restore services - Show archived toggle on services list - Migration adds deleted_at column Docs: Updated TASKS.md, CLAUDE.md, PROJECT_DEVELOPMENT.md, MEMORY.md - Phase 3 marked complete, test counts updated (252 passing) - SupportPal references replaced with standalone ticket system - Frontend design skill background rule added - Closed GitHub issues #3, #6, #7, #8, #9 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1248 lines
48 KiB
Vue
1248 lines
48 KiB
Vue
<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
|
|
discord: 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: '',
|
|
pterodactyl_api_url: (props.settings.api.pterodactyl_api_url as string) ?? '',
|
|
pterodactyl_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: '',
|
|
enhance_organization_id: '',
|
|
})
|
|
|
|
// 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',
|
|
bandwidth_alert_75: (props.settings.billing.bandwidth_alert_75 as string) === '1',
|
|
bandwidth_alert_90: (props.settings.billing.bandwidth_alert_90 as string) === '1',
|
|
bandwidth_alert_100: (props.settings.billing.bandwidth_alert_100 as string) === '1',
|
|
bandwidth_alert_75_email: (props.settings.billing.bandwidth_alert_75_email as string) === '1',
|
|
bandwidth_alert_90_email: (props.settings.billing.bandwidth_alert_90_email as string) === '1',
|
|
bandwidth_alert_100_email: (props.settings.billing.bandwidth_alert_100_email as string) === '1',
|
|
bandwidth_grace_period_days: (props.settings.billing.bandwidth_grace_period_days as string) ?? '3',
|
|
bandwidth_auto_suspend: (props.settings.billing.bandwidth_auto_suspend as string) === '1',
|
|
})
|
|
|
|
// Notifications settings form
|
|
const notificationsForm = useForm({
|
|
group: 'notifications',
|
|
email_from_address: (props.settings.notifications.email_from_address as string) ?? '',
|
|
email_from_name: (props.settings.notifications.email_from_name as string) ?? '',
|
|
})
|
|
|
|
// Discord webhooks form
|
|
const discordForm = useForm({
|
|
group: 'discord',
|
|
discord_payment_webhook_url: (props.settings.discord.discord_payment_webhook_url as string) ?? '',
|
|
discord_payment_webhook_enabled: (props.settings.discord.discord_payment_webhook_enabled as string) === '1',
|
|
discord_provisioning_webhook_url: (props.settings.discord.discord_provisioning_webhook_url as string) ?? '',
|
|
discord_provisioning_webhook_enabled: (props.settings.discord.discord_provisioning_webhook_enabled as string) === '1',
|
|
discord_support_webhook_url: (props.settings.discord.discord_support_webhook_url as string) ?? '',
|
|
discord_support_webhook_enabled: (props.settings.discord.discord_support_webhook_enabled as string) === '1',
|
|
discord_system_webhook_url: (props.settings.discord.discord_system_webhook_url as string) ?? '',
|
|
discord_system_webhook_enabled: (props.settings.discord.discord_system_webhook_enabled as string) === '1',
|
|
})
|
|
|
|
// Visibility toggles for sensitive API fields
|
|
const showVirtfusionToken = ref<boolean>(false)
|
|
const showPterodactylToken = ref<boolean>(false)
|
|
const showSynergycpToken = ref<boolean>(false)
|
|
const showEnhanceToken = ref<boolean>(false)
|
|
const showEnhanceOrgId = ref<boolean>(false)
|
|
|
|
// API connection test state
|
|
interface TestResult {
|
|
loading: boolean
|
|
success: boolean | null
|
|
message: string
|
|
}
|
|
|
|
const apiTestResults = ref<Record<string, TestResult>>({
|
|
virtfusion: { loading: false, success: null, message: '' },
|
|
pterodactyl: { loading: false, success: null, message: '' },
|
|
synergycp: { loading: false, success: null, message: '' },
|
|
enhance: { loading: false, success: null, message: '' },
|
|
})
|
|
|
|
// Discord webhook test state
|
|
const discordTestResults = ref<Record<string, TestResult>>({
|
|
payment: { loading: false, success: null, message: '' },
|
|
provisioning: { loading: false, success: null, message: '' },
|
|
support: { loading: false, success: null, message: '' },
|
|
system: { loading: false, success: null, message: '' },
|
|
})
|
|
|
|
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: 'discord', title: 'Discord Webhooks', icon: 'tabler-brand-discord' },
|
|
{ value: 'billing', title: 'Billing & Bandwidth', 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,
|
|
})
|
|
}
|
|
|
|
function submitDiscord(): void {
|
|
discordForm.put('/settings', {
|
|
preserveScroll: true,
|
|
})
|
|
}
|
|
|
|
async function testApiConnection(provider: string): Promise<void> {
|
|
const result = apiTestResults.value[provider]
|
|
if (!result) return
|
|
|
|
result.loading = true
|
|
result.success = null
|
|
result.message = ''
|
|
|
|
const urlKey = `${provider}_api_url` as keyof typeof apiForm
|
|
const tokenKey = `${provider}_api_token` as keyof typeof apiForm
|
|
|
|
try {
|
|
const response = await fetch('/settings/test-api', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
provider,
|
|
url: apiForm[urlKey] || null,
|
|
token: apiForm[tokenKey] || null,
|
|
organization_id: provider === 'enhance' ? (apiForm.enhance_organization_id || null) : null,
|
|
}),
|
|
})
|
|
|
|
const data = await response.json() as { success: boolean; message: string }
|
|
result.success = data.success
|
|
result.message = data.message
|
|
}
|
|
catch (e) {
|
|
result.success = false
|
|
result.message = 'Network error. Please try again.'
|
|
}
|
|
finally {
|
|
result.loading = false
|
|
}
|
|
}
|
|
|
|
async function testDiscordWebhook(channel: string): Promise<void> {
|
|
const result = discordTestResults.value[channel]
|
|
if (!result) return
|
|
|
|
result.loading = true
|
|
result.success = null
|
|
result.message = ''
|
|
|
|
const urlKey = `discord_${channel}_webhook_url` as keyof typeof discordForm
|
|
const webhookUrl = discordForm[urlKey] as string
|
|
|
|
if (!webhookUrl) {
|
|
result.success = false
|
|
result.message = 'Please enter a webhook URL first.'
|
|
result.loading = false
|
|
return
|
|
}
|
|
|
|
try {
|
|
const response = await fetch('/settings/test-discord', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement)?.content ?? '',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
webhook_url: webhookUrl,
|
|
channel,
|
|
}),
|
|
})
|
|
|
|
const data = await response.json() as { success: boolean; message: string }
|
|
result.success = data.success
|
|
result.message = data.message
|
|
}
|
|
catch (e) {
|
|
result.success = false
|
|
result.message = 'Network error. Please try again.'
|
|
}
|
|
finally {
|
|
result.loading = false
|
|
}
|
|
}
|
|
</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="d-flex align-center mb-3">
|
|
<VIcon icon="tabler-server" class="me-2" />
|
|
<span class="text-h6">VirtFusion (VPS)</span>
|
|
<VSpacer />
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="apiTestResults.virtfusion.loading"
|
|
@click="testApiConnection('virtfusion')"
|
|
>
|
|
<VIcon icon="tabler-plug-connected" start />
|
|
Test Connection
|
|
</VBtn>
|
|
</div>
|
|
<VAlert
|
|
v-if="apiTestResults.virtfusion.message"
|
|
:type="apiTestResults.virtfusion.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="apiTestResults.virtfusion.message = ''"
|
|
>
|
|
{{ apiTestResults.virtfusion.message }}
|
|
</VAlert>
|
|
<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" />
|
|
|
|
<!-- Pterodactyl -->
|
|
<div class="d-flex align-center mb-3">
|
|
<VIcon icon="tabler-device-gamepad-2" class="me-2" />
|
|
<span class="text-h6">Pterodactyl (Game Servers)</span>
|
|
<VSpacer />
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="apiTestResults.pterodactyl.loading"
|
|
@click="testApiConnection('pterodactyl')"
|
|
>
|
|
<VIcon icon="tabler-plug-connected" start />
|
|
Test Connection
|
|
</VBtn>
|
|
</div>
|
|
<VAlert
|
|
v-if="apiTestResults.pterodactyl.message"
|
|
:type="apiTestResults.pterodactyl.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="apiTestResults.pterodactyl.message = ''"
|
|
>
|
|
{{ apiTestResults.pterodactyl.message }}
|
|
</VAlert>
|
|
<VRow class="mb-4">
|
|
<VCol cols="12" md="6">
|
|
<AppTextField
|
|
v-model="apiForm.pterodactyl_api_url"
|
|
label="Panel URL"
|
|
placeholder="https://game.ezscale.cloud"
|
|
:error-messages="apiForm.errors.pterodactyl_api_url"
|
|
/>
|
|
</VCol>
|
|
<VCol cols="12" md="6">
|
|
<AppTextField
|
|
v-model="apiForm.pterodactyl_api_token"
|
|
label="API Key"
|
|
:type="showPterodactylToken ? 'text' : 'password'"
|
|
:placeholder="props.settings.api.pterodactyl_api_token_set ? '******** (key is set, leave blank to keep)' : 'Enter API key'"
|
|
:error-messages="apiForm.errors.pterodactyl_api_token"
|
|
>
|
|
<template #append-inner>
|
|
<VIcon
|
|
:icon="showPterodactylToken ? 'tabler-eye-off' : 'tabler-eye'"
|
|
style="cursor: pointer;"
|
|
@click="showPterodactylToken = !showPterodactylToken"
|
|
/>
|
|
</template>
|
|
</AppTextField>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<VDivider class="mb-4" />
|
|
|
|
<!-- SynergyCP -->
|
|
<div class="d-flex align-center mb-3">
|
|
<VIcon icon="tabler-server-2" class="me-2" />
|
|
<span class="text-h6">SynergyCP (Dedicated)</span>
|
|
<VSpacer />
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="apiTestResults.synergycp.loading"
|
|
@click="testApiConnection('synergycp')"
|
|
>
|
|
<VIcon icon="tabler-plug-connected" start />
|
|
Test Connection
|
|
</VBtn>
|
|
</div>
|
|
<VAlert
|
|
v-if="apiTestResults.synergycp.message"
|
|
:type="apiTestResults.synergycp.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="apiTestResults.synergycp.message = ''"
|
|
>
|
|
{{ apiTestResults.synergycp.message }}
|
|
</VAlert>
|
|
<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="d-flex align-center mb-3">
|
|
<VIcon icon="tabler-world" class="me-2" />
|
|
<span class="text-h6">Enhance (Web Hosting)</span>
|
|
<VSpacer />
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="apiTestResults.enhance.loading"
|
|
@click="testApiConnection('enhance')"
|
|
>
|
|
<VIcon icon="tabler-plug-connected" start />
|
|
Test Connection
|
|
</VBtn>
|
|
</div>
|
|
<VAlert
|
|
v-if="apiTestResults.enhance.message"
|
|
:type="apiTestResults.enhance.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="apiTestResults.enhance.message = ''"
|
|
>
|
|
{{ apiTestResults.enhance.message }}
|
|
</VAlert>
|
|
<VRow class="mb-4">
|
|
<VCol cols="12" md="4">
|
|
<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="4">
|
|
<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>
|
|
<VCol cols="12" md="4">
|
|
<AppTextField
|
|
v-model="apiForm.enhance_organization_id"
|
|
label="Organization ID"
|
|
:type="showEnhanceOrgId ? 'text' : 'password'"
|
|
:placeholder="props.settings.api.enhance_organization_id_set ? '******** (ID is set, leave blank to keep)' : 'Enter organization ID'"
|
|
:error-messages="apiForm.errors.enhance_organization_id"
|
|
>
|
|
<template #append-inner>
|
|
<VIcon
|
|
:icon="showEnhanceOrgId ? 'tabler-eye-off' : 'tabler-eye'"
|
|
style="cursor: pointer;"
|
|
@click="showEnhanceOrgId = !showEnhanceOrgId"
|
|
/>
|
|
</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>
|
|
|
|
<!-- Discord Webhooks Tab -->
|
|
<VTabsWindowItem value="discord">
|
|
<form @submit.prevent="submitDiscord">
|
|
<VAlert type="info" variant="tonal" class="mb-6">
|
|
Configure Discord webhook URLs to receive real-time notifications in your Discord server.
|
|
Each channel can be independently enabled or disabled.
|
|
</VAlert>
|
|
|
|
<!-- Payment Notifications -->
|
|
<VCard variant="outlined" class="mb-4">
|
|
<VCardItem>
|
|
<template #prepend>
|
|
<VIcon icon="tabler-cash" color="success" />
|
|
</template>
|
|
<VCardTitle>Payment Notifications</VCardTitle>
|
|
<VCardSubtitle>Receive alerts for successful and failed payments</VCardSubtitle>
|
|
<template #append>
|
|
<div class="d-flex align-center gap-3">
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="discordTestResults.payment.loading"
|
|
@click="testDiscordWebhook('payment')"
|
|
>
|
|
<VIcon icon="tabler-send" start />
|
|
Test
|
|
</VBtn>
|
|
<VSwitch
|
|
v-model="discordForm.discord_payment_webhook_enabled"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
/>
|
|
</div>
|
|
</template>
|
|
</VCardItem>
|
|
<VCardText>
|
|
<VAlert
|
|
v-if="discordTestResults.payment.message"
|
|
:type="discordTestResults.payment.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="discordTestResults.payment.message = ''"
|
|
>
|
|
{{ discordTestResults.payment.message }}
|
|
</VAlert>
|
|
<AppTextField
|
|
v-model="discordForm.discord_payment_webhook_url"
|
|
label="Webhook URL"
|
|
:placeholder="props.settings.discord.discord_payment_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
|
:error-messages="discordForm.errors.discord_payment_webhook_url"
|
|
>
|
|
<template #prepend-inner>
|
|
<VIcon icon="tabler-brand-discord" />
|
|
</template>
|
|
</AppTextField>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Provisioning Alerts -->
|
|
<VCard variant="outlined" class="mb-4">
|
|
<VCardItem>
|
|
<template #prepend>
|
|
<VIcon icon="tabler-server" color="info" />
|
|
</template>
|
|
<VCardTitle>Provisioning Alerts</VCardTitle>
|
|
<VCardSubtitle>Get notified when services are provisioned, suspended, or terminated</VCardSubtitle>
|
|
<template #append>
|
|
<div class="d-flex align-center gap-3">
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="discordTestResults.provisioning.loading"
|
|
@click="testDiscordWebhook('provisioning')"
|
|
>
|
|
<VIcon icon="tabler-send" start />
|
|
Test
|
|
</VBtn>
|
|
<VSwitch
|
|
v-model="discordForm.discord_provisioning_webhook_enabled"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
/>
|
|
</div>
|
|
</template>
|
|
</VCardItem>
|
|
<VCardText>
|
|
<VAlert
|
|
v-if="discordTestResults.provisioning.message"
|
|
:type="discordTestResults.provisioning.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="discordTestResults.provisioning.message = ''"
|
|
>
|
|
{{ discordTestResults.provisioning.message }}
|
|
</VAlert>
|
|
<AppTextField
|
|
v-model="discordForm.discord_provisioning_webhook_url"
|
|
label="Webhook URL"
|
|
:placeholder="props.settings.discord.discord_provisioning_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
|
:error-messages="discordForm.errors.discord_provisioning_webhook_url"
|
|
>
|
|
<template #prepend-inner>
|
|
<VIcon icon="tabler-brand-discord" />
|
|
</template>
|
|
</AppTextField>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- Support Ticket -->
|
|
<VCard variant="outlined" class="mb-4">
|
|
<VCardItem>
|
|
<template #prepend>
|
|
<VIcon icon="tabler-headset" color="warning" />
|
|
</template>
|
|
<VCardTitle>Support Ticket Notifications</VCardTitle>
|
|
<VCardSubtitle>Get notified when tickets are created or updated</VCardSubtitle>
|
|
<template #append>
|
|
<div class="d-flex align-center gap-3">
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="discordTestResults.support.loading"
|
|
@click="testDiscordWebhook('support')"
|
|
>
|
|
<VIcon icon="tabler-send" start />
|
|
Test
|
|
</VBtn>
|
|
<VSwitch
|
|
v-model="discordForm.discord_support_webhook_enabled"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
/>
|
|
</div>
|
|
</template>
|
|
</VCardItem>
|
|
<VCardText>
|
|
<VAlert
|
|
v-if="discordTestResults.support.message"
|
|
:type="discordTestResults.support.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="discordTestResults.support.message = ''"
|
|
>
|
|
{{ discordTestResults.support.message }}
|
|
</VAlert>
|
|
<AppTextField
|
|
v-model="discordForm.discord_support_webhook_url"
|
|
label="Webhook URL"
|
|
:placeholder="props.settings.discord.discord_support_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
|
:error-messages="discordForm.errors.discord_support_webhook_url"
|
|
>
|
|
<template #prepend-inner>
|
|
<VIcon icon="tabler-brand-discord" />
|
|
</template>
|
|
</AppTextField>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<!-- System Alerts -->
|
|
<VCard variant="outlined" class="mb-6">
|
|
<VCardItem>
|
|
<template #prepend>
|
|
<VIcon icon="tabler-alert-triangle" color="error" />
|
|
</template>
|
|
<VCardTitle>System Alerts</VCardTitle>
|
|
<VCardSubtitle>Critical system notifications, errors, and warnings</VCardSubtitle>
|
|
<template #append>
|
|
<div class="d-flex align-center gap-3">
|
|
<VBtn
|
|
size="small"
|
|
variant="outlined"
|
|
color="info"
|
|
:loading="discordTestResults.system.loading"
|
|
@click="testDiscordWebhook('system')"
|
|
>
|
|
<VIcon icon="tabler-send" start />
|
|
Test
|
|
</VBtn>
|
|
<VSwitch
|
|
v-model="discordForm.discord_system_webhook_enabled"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
/>
|
|
</div>
|
|
</template>
|
|
</VCardItem>
|
|
<VCardText>
|
|
<VAlert
|
|
v-if="discordTestResults.system.message"
|
|
:type="discordTestResults.system.success ? 'success' : 'error'"
|
|
variant="tonal"
|
|
closable
|
|
class="mb-3"
|
|
@click:close="discordTestResults.system.message = ''"
|
|
>
|
|
{{ discordTestResults.system.message }}
|
|
</VAlert>
|
|
<AppTextField
|
|
v-model="discordForm.discord_system_webhook_url"
|
|
label="Webhook URL"
|
|
:placeholder="props.settings.discord.discord_system_webhook_url_set ? '******** (URL is set, leave blank to keep)' : 'https://discord.com/api/webhooks/...'"
|
|
:error-messages="discordForm.errors.discord_system_webhook_url"
|
|
>
|
|
<template #prepend-inner>
|
|
<VIcon icon="tabler-brand-discord" />
|
|
</template>
|
|
</AppTextField>
|
|
</VCardText>
|
|
</VCard>
|
|
|
|
<VBtn
|
|
type="submit"
|
|
color="primary"
|
|
:loading="discordForm.processing"
|
|
:disabled="discordForm.processing"
|
|
>
|
|
<VIcon icon="tabler-device-floppy" start />
|
|
Save Discord Settings
|
|
</VBtn>
|
|
</form>
|
|
</VTabsWindowItem>
|
|
|
|
<!-- Billing & Bandwidth Tab -->
|
|
<VTabsWindowItem value="billing">
|
|
<form @submit.prevent="submitBilling">
|
|
<!-- Billing Settings Section -->
|
|
<div class="text-h6 mb-3">
|
|
<VIcon icon="tabler-credit-card" start />
|
|
Billing Configuration
|
|
</div>
|
|
|
|
<VRow class="mb-6">
|
|
<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="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">
|
|
<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>
|
|
</VRow>
|
|
|
|
<VDivider class="mb-6" />
|
|
|
|
<!-- Bandwidth Overage Section -->
|
|
<div class="text-h6 mb-3">
|
|
<VIcon icon="tabler-chart-arrows" start />
|
|
Bandwidth Overage Rates
|
|
</div>
|
|
|
|
<VRow class="mb-4">
|
|
<VCol cols="12" md="4">
|
|
<AppTextField
|
|
v-model="billingForm.bandwidth_overage_rate"
|
|
label="Overage Rate ($/GB)"
|
|
type="number"
|
|
step="0.01"
|
|
min="0"
|
|
placeholder="0.05"
|
|
:error-messages="billingForm.errors.bandwidth_overage_rate"
|
|
>
|
|
<template #append-inner>
|
|
<VTooltip location="top">
|
|
<template #activator="{ props: tooltipProps }">
|
|
<VIcon
|
|
v-bind="tooltipProps"
|
|
icon="tabler-info-circle"
|
|
size="18"
|
|
/>
|
|
</template>
|
|
Price charged per GB over the plan's included bandwidth
|
|
</VTooltip>
|
|
</template>
|
|
</AppTextField>
|
|
</VCol>
|
|
|
|
<VCol cols="12" md="4">
|
|
<AppTextField
|
|
v-model="billingForm.bandwidth_grace_period_days"
|
|
label="Grace Period Before Billing (days)"
|
|
type="number"
|
|
min="0"
|
|
max="365"
|
|
placeholder="3"
|
|
:error-messages="billingForm.errors.bandwidth_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 overage detected before billing begins
|
|
</VTooltip>
|
|
</template>
|
|
</AppTextField>
|
|
</VCol>
|
|
|
|
<VCol cols="12" md="4">
|
|
<div class="d-flex align-center pt-6" style="min-height: 56px;">
|
|
<VSwitch
|
|
v-model="billingForm.bandwidth_auto_suspend"
|
|
label="Auto-suspend on overage"
|
|
color="error"
|
|
hide-details
|
|
inset
|
|
/>
|
|
<VTooltip location="top">
|
|
<template #activator="{ props: tooltipProps }">
|
|
<VIcon
|
|
v-bind="tooltipProps"
|
|
icon="tabler-info-circle"
|
|
size="18"
|
|
class="ms-2"
|
|
/>
|
|
</template>
|
|
Automatically suspend services that exceed their bandwidth limit
|
|
</VTooltip>
|
|
</div>
|
|
</VCol>
|
|
</VRow>
|
|
|
|
<!-- Alert Thresholds -->
|
|
<div class="text-subtitle-1 font-weight-medium mb-3">
|
|
Alert Thresholds
|
|
</div>
|
|
|
|
<VCard variant="outlined" class="mb-6">
|
|
<VTable>
|
|
<thead>
|
|
<tr>
|
|
<th>Threshold</th>
|
|
<th class="text-center">
|
|
Alert Enabled
|
|
</th>
|
|
<th class="text-center">
|
|
Email Notification
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
<tr>
|
|
<td>
|
|
<div class="d-flex align-center">
|
|
<VChip color="warning" variant="tonal" size="small" class="me-2">
|
|
75%
|
|
</VChip>
|
|
Bandwidth usage at 75%
|
|
</div>
|
|
</td>
|
|
<td class="text-center">
|
|
<VSwitch
|
|
v-model="billingForm.bandwidth_alert_75"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
class="d-inline-flex"
|
|
/>
|
|
</td>
|
|
<td class="text-center">
|
|
<VSwitch
|
|
v-model="billingForm.bandwidth_alert_75_email"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
:disabled="!billingForm.bandwidth_alert_75"
|
|
class="d-inline-flex"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<div class="d-flex align-center">
|
|
<VChip color="error" variant="tonal" size="small" class="me-2">
|
|
90%
|
|
</VChip>
|
|
Bandwidth usage at 90%
|
|
</div>
|
|
</td>
|
|
<td class="text-center">
|
|
<VSwitch
|
|
v-model="billingForm.bandwidth_alert_90"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
class="d-inline-flex"
|
|
/>
|
|
</td>
|
|
<td class="text-center">
|
|
<VSwitch
|
|
v-model="billingForm.bandwidth_alert_90_email"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
:disabled="!billingForm.bandwidth_alert_90"
|
|
class="d-inline-flex"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
<tr>
|
|
<td>
|
|
<div class="d-flex align-center">
|
|
<VChip color="error" size="small" class="me-2">
|
|
100%
|
|
</VChip>
|
|
Bandwidth limit reached
|
|
</div>
|
|
</td>
|
|
<td class="text-center">
|
|
<VSwitch
|
|
v-model="billingForm.bandwidth_alert_100"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
class="d-inline-flex"
|
|
/>
|
|
</td>
|
|
<td class="text-center">
|
|
<VSwitch
|
|
v-model="billingForm.bandwidth_alert_100_email"
|
|
color="primary"
|
|
hide-details
|
|
inset
|
|
:disabled="!billingForm.bandwidth_alert_100"
|
|
class="d-inline-flex"
|
|
/>
|
|
</td>
|
|
</tr>
|
|
</tbody>
|
|
</VTable>
|
|
</VCard>
|
|
|
|
<VBtn
|
|
type="submit"
|
|
color="primary"
|
|
:loading="billingForm.processing"
|
|
:disabled="billingForm.processing"
|
|
>
|
|
<VIcon icon="tabler-device-floppy" start />
|
|
Save Billing & Bandwidth Settings
|
|
</VBtn>
|
|
</form>
|
|
</VTabsWindowItem>
|
|
|
|
<!-- Notifications Tab -->
|
|
<VTabsWindowItem value="notifications">
|
|
<form @submit.prevent="submitNotifications">
|
|
<VRow>
|
|
<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">
|
|
<VAlert type="info" variant="tonal" class="mb-4">
|
|
Discord webhook notifications are configured in the
|
|
<a
|
|
href="#"
|
|
class="text-primary font-weight-medium"
|
|
@click.prevent="activeTab = 'discord'"
|
|
>
|
|
Discord Webhooks
|
|
</a>
|
|
tab.
|
|
</VAlert>
|
|
</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>
|