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