Add account settings page and admin analytics dashboard

Phase 4 (Customer Dashboard):
- Build tabbed account settings page (Account, Security, Billing tabs)
- Account tab: profile info, address, company fields with useForm()
- Security tab: password change, 2FA management, device sessions
- Billing tab: payment methods link, billing address, tax ID
- Create UpdateProfileRequest and UpdatePasswordRequest validators
- Expand ProfileController with update, updatePassword, updateBilling

Phase 5 (Admin Panel):
- Build rich admin analytics dashboard with 14 data points
- Stats: total customers, MRR, active services, pending invoices
- Recent subscriptions and invoices tables with customer info
- Popular plans with subscriber counts
- Revenue by service type breakdown
- Quick stats: monthly revenue, new customers, overdue accounts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 10:25:41 -05:00
parent 33e86a32a8
commit dc998b4d7c
12 changed files with 1591 additions and 87 deletions

View File

@@ -1,24 +1,128 @@
<script lang="ts" setup>
import AdminLayout from '@/Layouts/AdminLayout.vue'
import StatCard from '@/Components/StatCard.vue'
import { resolveInvoiceStatusColor, resolveSubscriptionStatusColor } from '@/utils/resolvers'
interface RecentInvoice {
id: number
number: string
total: string
status: string
gateway: string
created_at: string
user: {
id: number
name: string
email: string
} | null
}
interface RecentSubscription {
id: number
user_id: number
plan_id: number | null
type: string
stripe_status: string
gateway: string
created_at: string
plan_name: string | null
plan_price: string | null
plan_billing_cycle: string | null
user_name: string | null
user_email: string | null
}
interface PopularPlan {
id: number
name: string
service_type: string
price: string
billing_cycle: string
active_services_count: number
}
interface RevenueByServiceType {
service_type: string
revenue: string
invoice_count: number
}
interface Props {
totalCustomers: number
totalServices: number
mrr: number
totalRevenue: number
activeServices: number
pendingInvoicesCount: number
pendingInvoicesAmount: number
overdueCount: number
overdueAmount: number
recentInvoices: RecentInvoice[]
recentSubscriptions: RecentSubscription[]
popularPlans: PopularPlan[]
revenueByServiceType: RevenueByServiceType[]
newCustomersThisMonth: number
revenueThisMonth: number
}
defineOptions({ layout: AdminLayout })
defineProps<Props>()
const props = defineProps<Props>()
function formatCurrency(value: number | string): string {
const num = typeof value === 'string' ? parseFloat(value) : value
return `$${num.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr)
return date.toLocaleDateString('en-US', { month: 'short', day: '2-digit', year: 'numeric' })
}
function resolveServiceTypeColor(type: string): string {
const map: Record<string, string> = {
vps: 'primary',
dedicated: 'info',
web_hosting: 'success',
game: 'warning',
}
return map[type] ?? 'secondary'
}
function resolveServiceTypeIcon(type: string): string {
const map: Record<string, string> = {
vps: 'tabler-server',
dedicated: 'tabler-server-2',
web_hosting: 'tabler-world',
game: 'tabler-device-gamepad-2',
}
return map[type] ?? 'tabler-box'
}
function formatServiceType(type: string): string {
const map: Record<string, string> = {
vps: 'VPS',
dedicated: 'Dedicated',
web_hosting: 'Web Hosting',
game: 'Game Hosting',
}
return map[type] ?? type
}
</script>
<template>
<div>
<div class="text-h4 font-weight-bold mb-6">Admin Dashboard</div>
<div class="d-flex align-center justify-space-between mb-6">
<div>
<div class="text-h4 font-weight-bold">Admin Dashboard</div>
<div class="text-body-2 text-medium-emphasis">
Overview of your business metrics and recent activity
</div>
</div>
</div>
<VRow>
<VCol cols="12" md="4">
<!-- Row 1: Key Metrics -->
<VRow class="mb-2">
<VCol cols="12" sm="6" lg="3">
<StatCard
title="Total Customers"
:stats="totalCustomers"
@@ -27,23 +131,319 @@ defineProps<Props>()
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" sm="6" lg="3">
<StatCard
title="Total Services"
:stats="totalServices"
title="Monthly Recurring Revenue"
:stats="formatCurrency(mrr)"
icon="tabler-currency-dollar"
color="success"
/>
</VCol>
<VCol cols="12" sm="6" lg="3">
<StatCard
title="Active Services"
:stats="activeServices"
icon="tabler-server"
color="info"
/>
</VCol>
<VCol cols="12" md="4">
<VCol cols="12" sm="6" lg="3">
<StatCard
title="Active Services"
:stats="activeServices"
icon="tabler-circle-check"
color="success"
title="Pending Invoices"
:stats="`${pendingInvoicesCount} (${formatCurrency(pendingInvoicesAmount)})`"
icon="tabler-alert-triangle"
color="warning"
/>
</VCol>
</VRow>
<!-- Row 2: Recent Subscriptions & Recent Invoices -->
<VRow class="mb-2">
<!-- Recent Subscriptions -->
<VCol cols="12" lg="6">
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<VIcon icon="tabler-receipt" size="22" />
<span>Recent Subscriptions</span>
</div>
<VChip size="small" color="primary" variant="tonal">
Latest 10
</VChip>
</VCardTitle>
<VCardText v-if="recentSubscriptions.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">No subscriptions yet.</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Customer</th>
<th>Plan</th>
<th>Status</th>
<th class="text-end">Price</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr v-for="sub in recentSubscriptions" :key="sub.id">
<td>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ sub.user_name ?? 'Unknown' }}</span>
<span class="text-caption text-medium-emphasis">{{ sub.user_email ?? '' }}</span>
</div>
</td>
<td>
<span class="text-body-2">{{ sub.plan_name ?? sub.type }}</span>
</td>
<td>
<VChip
:color="resolveSubscriptionStatusColor(sub.stripe_status)"
size="small"
class="text-capitalize"
>
{{ sub.stripe_status }}
</VChip>
</td>
<td class="text-end">
<template v-if="sub.plan_price">
{{ formatCurrency(sub.plan_price) }}/{{ sub.plan_billing_cycle ?? 'mo' }}
</template>
<template v-else>
<span class="text-medium-emphasis">&mdash;</span>
</template>
</td>
<td class="text-body-2">
{{ formatDate(sub.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
<!-- Recent Invoices -->
<VCol cols="12" lg="6">
<VCard>
<VCardTitle class="d-flex align-center justify-space-between">
<div class="d-flex align-center gap-2">
<VIcon icon="tabler-file-invoice" size="22" />
<span>Recent Invoices</span>
</div>
<VChip size="small" color="info" variant="tonal">
Latest 10
</VChip>
</VCardTitle>
<VCardText v-if="recentInvoices.length === 0" class="text-center py-8">
<VIcon icon="tabler-inbox" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">No invoices yet.</div>
</VCardText>
<VTable v-else density="comfortable" hover>
<thead>
<tr>
<th>Invoice #</th>
<th>Customer</th>
<th>Status</th>
<th class="text-end">Amount</th>
<th>Date</th>
</tr>
</thead>
<tbody>
<tr v-for="invoice in recentInvoices" :key="invoice.id">
<td class="text-body-2 font-weight-medium">
{{ invoice.number }}
</td>
<td>
<div class="d-flex flex-column">
<span class="text-body-2 font-weight-medium">{{ invoice.user?.name ?? 'Unknown' }}</span>
<span class="text-caption text-medium-emphasis">{{ invoice.user?.email ?? '' }}</span>
</div>
</td>
<td>
<VChip
:color="resolveInvoiceStatusColor(invoice.status)"
size="small"
class="text-capitalize"
>
{{ invoice.status }}
</VChip>
</td>
<td class="text-end text-body-2 font-weight-medium">
{{ formatCurrency(invoice.total) }}
</td>
<td class="text-body-2">
{{ formatDate(invoice.created_at) }}
</td>
</tr>
</tbody>
</VTable>
</VCard>
</VCol>
</VRow>
<!-- Row 3: Popular Plans & Quick Stats -->
<VRow>
<!-- Popular Plans -->
<VCol cols="12" lg="6">
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-star" size="22" />
<span>Popular Plans</span>
</VCardTitle>
<VCardText v-if="popularPlans.length === 0" class="text-center py-8">
<VIcon icon="tabler-package" size="48" color="disabled" class="mb-2" />
<div class="text-medium-emphasis">No plans configured yet.</div>
</VCardText>
<VList v-else lines="two" density="comfortable">
<VListItem
v-for="plan in popularPlans"
:key="plan.id"
>
<template #prepend>
<VAvatar
:color="resolveServiceTypeColor(plan.service_type)"
variant="tonal"
rounded
size="40"
>
<VIcon :icon="resolveServiceTypeIcon(plan.service_type)" size="22" />
</VAvatar>
</template>
<VListItemTitle class="font-weight-medium">
{{ plan.name }}
</VListItemTitle>
<VListItemSubtitle>
{{ formatServiceType(plan.service_type) }} &middot; {{ formatCurrency(plan.price) }}/{{ plan.billing_cycle }}
</VListItemSubtitle>
<template #append>
<div class="text-end">
<div class="text-body-2 font-weight-semibold">
{{ plan.active_services_count }}
</div>
<div class="text-caption text-medium-emphasis">
{{ plan.active_services_count === 1 ? 'subscriber' : 'subscribers' }}
</div>
</div>
</template>
</VListItem>
</VList>
</VCard>
</VCol>
<!-- Quick Stats & Revenue by Type -->
<VCol cols="12" lg="6">
<!-- Quick Stats -->
<VCard class="mb-4">
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-chart-bar" size="22" />
<span>Quick Stats</span>
</VCardTitle>
<VCardText>
<VRow>
<VCol cols="6">
<div class="d-flex align-center gap-3 mb-4">
<VAvatar color="success" variant="tonal" rounded size="40">
<VIcon icon="tabler-trending-up" size="22" />
</VAvatar>
<div>
<div class="text-caption text-medium-emphasis">Total Revenue</div>
<div class="text-body-1 font-weight-semibold">{{ formatCurrency(totalRevenue) }}</div>
</div>
</div>
</VCol>
<VCol cols="6">
<div class="d-flex align-center gap-3 mb-4">
<VAvatar color="primary" variant="tonal" rounded size="40">
<VIcon icon="tabler-user-plus" size="22" />
</VAvatar>
<div>
<div class="text-caption text-medium-emphasis">New This Month</div>
<div class="text-body-1 font-weight-semibold">{{ newCustomersThisMonth }} customers</div>
</div>
</div>
</VCol>
<VCol cols="6">
<div class="d-flex align-center gap-3">
<VAvatar color="info" variant="tonal" rounded size="40">
<VIcon icon="tabler-report-money" size="22" />
</VAvatar>
<div>
<div class="text-caption text-medium-emphasis">Revenue This Month</div>
<div class="text-body-1 font-weight-semibold">{{ formatCurrency(revenueThisMonth) }}</div>
</div>
</div>
</VCol>
<VCol cols="6">
<div class="d-flex align-center gap-3">
<VAvatar :color="overdueCount > 0 ? 'error' : 'success'" variant="tonal" rounded size="40">
<VIcon :icon="overdueCount > 0 ? 'tabler-alert-circle' : 'tabler-circle-check'" size="22" />
</VAvatar>
<div>
<div class="text-caption text-medium-emphasis">Overdue Accounts</div>
<div class="text-body-1 font-weight-semibold" :class="overdueCount > 0 ? 'text-error' : ''">
{{ overdueCount }} ({{ formatCurrency(overdueAmount) }})
</div>
</div>
</div>
</VCol>
</VRow>
</VCardText>
</VCard>
<!-- Revenue by Service Type -->
<VCard>
<VCardTitle class="d-flex align-center gap-2">
<VIcon icon="tabler-report-analytics" size="22" />
<span>Revenue by Service Type</span>
</VCardTitle>
<VCardText v-if="revenueByServiceType.length === 0" class="text-center py-6">
<div class="text-medium-emphasis">No revenue data available yet.</div>
</VCardText>
<VList v-else density="comfortable">
<VListItem
v-for="item in revenueByServiceType"
:key="item.service_type"
>
<template #prepend>
<VAvatar
:color="resolveServiceTypeColor(item.service_type)"
variant="tonal"
rounded
size="36"
>
<VIcon :icon="resolveServiceTypeIcon(item.service_type)" size="20" />
</VAvatar>
</template>
<VListItemTitle class="font-weight-medium">
{{ formatServiceType(item.service_type) }}
</VListItemTitle>
<VListItemSubtitle>
{{ item.invoice_count }} {{ item.invoice_count === 1 ? 'invoice' : 'invoices' }} paid
</VListItemSubtitle>
<template #append>
<div class="text-body-2 font-weight-semibold">
{{ formatCurrency(item.revenue) }}
</div>
</template>
</VListItem>
</VList>
</VCard>
</VCol>
</VRow>
</div>
</template>

View File

@@ -0,0 +1,275 @@
<script lang="ts" setup>
import { ref, computed } from 'vue'
import { useForm } from '@inertiajs/vue3'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
import type { User, UserProfile } from '@/types'
interface Props {
user: User
profile: UserProfile | null
firstName: string
lastName: string
}
const props = defineProps<Props>()
const form = useForm({
first_name: props.firstName,
last_name: props.lastName,
phone: props.user.phone ?? '',
company: props.user.company ?? '',
address_line1: props.profile?.billing_address_line1 ?? '',
address_line2: props.profile?.billing_address_line2 ?? '',
city: props.profile?.billing_city ?? '',
state: props.profile?.billing_state ?? '',
zip: props.profile?.billing_zip ?? '',
country: props.profile?.billing_country ?? '',
})
const avatarInitials = computed<string>(() => {
const first = props.firstName.charAt(0).toUpperCase()
const last = props.lastName.charAt(0).toUpperCase()
return `${first}${last}` || '?'
})
const countries: string[] = [
'United States',
'Canada',
'United Kingdom',
'Australia',
'Germany',
'France',
'Netherlands',
'Japan',
'Singapore',
'India',
'Brazil',
]
const submit = (): void => {
form.put('/profile')
}
const resetForm = (): void => {
form.reset()
form.clearErrors()
}
</script>
<template>
<VRow>
<VCol cols="12">
<VCard>
<VCardText class="d-flex">
<VAvatar
rounded
size="100"
color="primary"
variant="tonal"
class="me-6"
>
<span class="text-h4">{{ avatarInitials }}</span>
</VAvatar>
<div class="d-flex flex-column justify-center gap-2">
<div class="d-flex flex-wrap gap-4">
<VBtn
color="primary"
size="small"
disabled
>
<VIcon
icon="tabler-cloud-upload"
class="d-sm-none"
/>
<span class="d-none d-sm-block">Upload new photo</span>
</VBtn>
<VBtn
size="small"
color="secondary"
variant="tonal"
disabled
>
<span class="d-none d-sm-block">Reset</span>
<VIcon
icon="tabler-refresh"
class="d-sm-none"
/>
</VBtn>
</div>
<p class="text-body-2 text-medium-emphasis mb-0">
Allowed JPG, GIF or PNG. Max size of 800K
</p>
</div>
</VCardText>
<VCardText class="pt-2">
<VForm
class="mt-3"
@submit.prevent="submit"
>
<VRow>
<VCol
md="6"
cols="12"
>
<AppTextField
v-model="form.first_name"
label="First Name"
placeholder="John"
:error-messages="form.errors.first_name ? [form.errors.first_name] : []"
/>
</VCol>
<VCol
md="6"
cols="12"
>
<AppTextField
v-model="form.last_name"
label="Last Name"
placeholder="Doe"
:error-messages="form.errors.last_name ? [form.errors.last_name] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
:model-value="user.email"
label="Email"
type="email"
disabled
placeholder="john@example.com"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.company"
label="Organization"
placeholder="EZSCALE"
:error-messages="form.errors.company ? [form.errors.company] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.phone"
label="Phone Number"
placeholder="+1 (917) 543-9876"
:error-messages="form.errors.phone ? [form.errors.phone] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.address_line1"
label="Address"
placeholder="123 Main St"
:error-messages="form.errors.address_line1 ? [form.errors.address_line1] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.address_line2"
label="Address Line 2"
placeholder="Apt 4B"
:error-messages="form.errors.address_line2 ? [form.errors.address_line2] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.city"
label="City"
placeholder="New York"
:error-messages="form.errors.city ? [form.errors.city] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.state"
label="State"
placeholder="New York"
:error-messages="form.errors.state ? [form.errors.state] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="form.zip"
label="Zip Code"
placeholder="10001"
:error-messages="form.errors.zip ? [form.errors.zip] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="form.country"
label="Country"
:items="countries"
placeholder="Select Country"
:error-messages="form.errors.country ? [form.errors.country] : []"
/>
</VCol>
<VCol
cols="12"
class="d-flex flex-wrap gap-4"
>
<VBtn
type="submit"
:loading="form.processing"
:disabled="form.processing"
>
Save changes
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click.prevent="resetForm"
>
Cancel
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,202 @@
<script lang="ts" setup>
import { Link } from '@inertiajs/vue3'
import { useForm } from '@inertiajs/vue3'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import AppSelect from '@/Components/app-form-elements/AppSelect.vue'
import type { UserProfile } from '@/types'
interface Props {
profile: UserProfile | null
}
const props = defineProps<Props>()
const billingForm = useForm({
billing_address_line1: props.profile?.billing_address_line1 ?? '',
billing_address_line2: props.profile?.billing_address_line2 ?? '',
billing_city: props.profile?.billing_city ?? '',
billing_state: props.profile?.billing_state ?? '',
billing_zip: props.profile?.billing_zip ?? '',
billing_country: props.profile?.billing_country ?? '',
tax_id: props.profile?.tax_id ?? '',
company_vat: props.profile?.company_vat ?? '',
})
const countries: string[] = [
'United States',
'Canada',
'United Kingdom',
'Australia',
'Germany',
'France',
'Netherlands',
'Japan',
'Singapore',
'India',
'Brazil',
]
const submitBilling = (): void => {
billingForm.put('/profile/billing')
}
const resetBillingForm = (): void => {
billingForm.reset()
billingForm.clearErrors()
}
</script>
<template>
<VRow>
<!-- Payment Methods Link -->
<VCol cols="12">
<VCard>
<VCardText class="d-flex align-center justify-space-between flex-wrap gap-4">
<div>
<h5 class="text-h5 mb-1">
Payment Methods
</h5>
<p class="text-body-2 text-medium-emphasis mb-0">
Manage your payment methods, view invoices, and transaction history.
</p>
</div>
<Link
href="/billing"
class="text-decoration-none"
>
<VBtn>
<VIcon
icon="tabler-credit-card"
start
/>
Manage Payment Methods
</VBtn>
</Link>
</VCardText>
</VCard>
</VCol>
<!-- Billing Address -->
<VCol cols="12">
<VCard title="Billing Address">
<VCardText>
<VForm @submit.prevent="submitBilling">
<VRow>
<VCol cols="12">
<AppTextField
v-model="billingForm.billing_address_line1"
label="Billing Address"
placeholder="123 Main St"
:error-messages="billingForm.errors.billing_address_line1 ? [billingForm.errors.billing_address_line1] : []"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="billingForm.billing_address_line2"
label="Address Line 2"
placeholder="Suite 100"
:error-messages="billingForm.errors.billing_address_line2 ? [billingForm.errors.billing_address_line2] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingForm.billing_city"
label="City"
placeholder="New York"
:error-messages="billingForm.errors.billing_city ? [billingForm.errors.billing_city] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingForm.billing_state"
label="State"
placeholder="New York"
:error-messages="billingForm.errors.billing_state ? [billingForm.errors.billing_state] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingForm.billing_zip"
label="Zip Code"
placeholder="10001"
:error-messages="billingForm.errors.billing_zip ? [billingForm.errors.billing_zip] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppSelect
v-model="billingForm.billing_country"
label="Country"
:items="countries"
placeholder="Select Country"
:error-messages="billingForm.errors.billing_country ? [billingForm.errors.billing_country] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingForm.tax_id"
label="Tax ID"
placeholder="123-45-6789"
:error-messages="billingForm.errors.tax_id ? [billingForm.errors.tax_id] : []"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="billingForm.company_vat"
label="VAT Number"
placeholder="GB123456789"
:error-messages="billingForm.errors.company_vat ? [billingForm.errors.company_vat] : []"
/>
</VCol>
<VCol
cols="12"
class="d-flex flex-wrap gap-4"
>
<VBtn
type="submit"
:loading="billingForm.processing"
:disabled="billingForm.processing"
>
Save changes
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click="resetBillingForm"
>
Discard
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>

View File

@@ -0,0 +1,351 @@
<script lang="ts" setup>
import { ref } from 'vue'
import { useForm, usePage, router } from '@inertiajs/vue3'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import type { SharedPageProps } from '@/types'
interface Props {
twoFactorEnabled: boolean
}
defineProps<Props>()
const page = usePage<SharedPageProps>()
const isCurrentPasswordVisible = ref<boolean>(false)
const isNewPasswordVisible = ref<boolean>(false)
const isConfirmPasswordVisible = ref<boolean>(false)
const passwordForm = useForm({
current_password: '',
password: '',
password_confirmation: '',
})
const submitPassword = (): void => {
passwordForm.put('/profile/password', {
preserveScroll: true,
onSuccess: () => {
passwordForm.reset()
},
})
}
const resetPasswordForm = (): void => {
passwordForm.reset()
passwordForm.clearErrors()
}
const passwordRequirements: string[] = [
'Minimum 8 characters long - the more, the better',
'At least one lowercase character',
'At least one uppercase character',
'At least one number, symbol, or whitespace character',
]
// Two-factor authentication
const enabling = ref<boolean>(false)
const confirming = ref<boolean>(false)
const disabling = ref<boolean>(false)
const qrCode = ref<string>('')
const recoveryCodes = ref<string[]>([])
const confirmationForm = useForm({
code: '',
})
const enableTwoFactor = (): void => {
enabling.value = true
router.post('/user/two-factor-authentication', {}, {
preserveScroll: true,
onSuccess: () => {
confirming.value = true
showQrCode()
showRecoveryCodes()
},
onFinish: () => {
enabling.value = false
},
})
}
const showQrCode = (): void => {
fetch('/user/two-factor-qr-code')
.then(r => r.json())
.then((data: { svg: string }) => { qrCode.value = data.svg })
}
const showRecoveryCodes = (): void => {
fetch('/user/two-factor-recovery-codes')
.then(r => r.json())
.then((data: string[]) => { recoveryCodes.value = data })
}
const confirmTwoFactor = (): void => {
confirmationForm.post('/user/confirmed-two-factor-authentication', {
preserveScroll: true,
onSuccess: () => {
confirming.value = false
},
})
}
const disableTwoFactor = (): void => {
disabling.value = true
router.delete('/user/two-factor-authentication', {
preserveScroll: true,
onSuccess: () => {
qrCode.value = ''
recoveryCodes.value = []
},
onFinish: () => {
disabling.value = false
},
})
}
</script>
<template>
<VRow>
<!-- Change Password -->
<VCol cols="12">
<VCard title="Change Password">
<VForm @submit.prevent="submitPassword">
<VCardText class="pt-0">
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="passwordForm.current_password"
:type="isCurrentPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isCurrentPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
label="Current Password"
autocomplete="current-password"
placeholder="............"
:error-messages="passwordForm.errors.current_password ? [passwordForm.errors.current_password] : []"
@click:append-inner="isCurrentPasswordVisible = !isCurrentPasswordVisible"
/>
</VCol>
</VRow>
<VRow>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="passwordForm.password"
:type="isNewPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isNewPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
label="New Password"
autocomplete="new-password"
placeholder="............"
:error-messages="passwordForm.errors.password ? [passwordForm.errors.password] : []"
@click:append-inner="isNewPasswordVisible = !isNewPasswordVisible"
/>
</VCol>
<VCol
cols="12"
md="6"
>
<AppTextField
v-model="passwordForm.password_confirmation"
:type="isConfirmPasswordVisible ? 'text' : 'password'"
:append-inner-icon="isConfirmPasswordVisible ? 'tabler-eye-off' : 'tabler-eye'"
label="Confirm New Password"
autocomplete="new-password"
placeholder="............"
:error-messages="passwordForm.errors.password_confirmation ? [passwordForm.errors.password_confirmation] : []"
@click:append-inner="isConfirmPasswordVisible = !isConfirmPasswordVisible"
/>
</VCol>
</VRow>
</VCardText>
<VCardText>
<h6 class="text-h6 text-medium-emphasis mb-4">
Password Requirements:
</h6>
<VList class="card-list">
<VListItem
v-for="item in passwordRequirements"
:key="item"
:title="item"
class="text-medium-emphasis"
>
<template #prepend>
<VIcon
size="10"
icon="tabler-circle-filled"
/>
</template>
</VListItem>
</VList>
</VCardText>
<VCardText class="d-flex flex-wrap gap-4">
<VBtn
type="submit"
:loading="passwordForm.processing"
:disabled="passwordForm.processing"
>
Save changes
</VBtn>
<VBtn
color="secondary"
variant="tonal"
@click="resetPasswordForm"
>
Reset
</VBtn>
</VCardText>
</VForm>
</VCard>
</VCol>
<!-- Two-Factor Authentication -->
<VCol cols="12">
<VCard title="Two-Factor Authentication">
<VCardText>
<div v-if="!twoFactorEnabled && !confirming">
<h5 class="text-h5 text-medium-emphasis mb-4">
Two-factor authentication is not enabled yet.
</h5>
<p class="mb-6">
Two-factor authentication adds an additional layer of security to your account by
requiring more than just a password to log in. You will need to enter a code from your
authenticator app each time you sign in.
</p>
<VBtn
:loading="enabling"
:disabled="enabling"
@click="enableTwoFactor"
>
Enable two-factor authentication
</VBtn>
</div>
<!-- QR Code confirmation step -->
<div v-if="confirming">
<div class="text-body-2 text-medium-emphasis mb-4">
Scan this QR code with your authenticator app, then enter the code below to confirm.
</div>
<!-- eslint-disable-next-line vue/no-v-html -->
<div
v-if="qrCode"
v-html="qrCode"
class="mb-4 d-inline-block pa-4 rounded"
style="background: white;"
/>
<VForm
@submit.prevent="confirmTwoFactor"
style="max-width: 300px;"
>
<AppTextField
v-model="confirmationForm.code"
label="Confirmation Code"
type="text"
inputmode="numeric"
placeholder="000000"
:error-messages="confirmationForm.errors.code ? [confirmationForm.errors.code] : []"
class="mb-4"
/>
<VBtn
type="submit"
:loading="confirmationForm.processing"
:disabled="confirmationForm.processing"
>
Confirm
</VBtn>
</VForm>
</div>
<!-- Recovery codes -->
<div v-if="recoveryCodes.length > 0 && !confirming">
<div class="text-subtitle-2 font-weight-bold mb-2">
Recovery Codes
</div>
<div class="text-body-2 text-medium-emphasis mb-3">
Store these codes in a safe place. They can be used to access your account if you lose your authenticator device.
</div>
<VSheet
rounded
color="surface-variant"
class="pa-4 font-weight-medium"
style="font-family: monospace;"
>
<div
v-for="code in recoveryCodes"
:key="code"
>
{{ code }}
</div>
</VSheet>
</div>
<!-- 2FA enabled state -->
<div v-if="twoFactorEnabled && !confirming">
<VAlert
type="success"
variant="tonal"
class="mb-4"
>
Two-factor authentication is enabled.
</VAlert>
<VBtn
color="error"
variant="tonal"
:loading="disabling"
:disabled="disabling"
@click="disableTwoFactor"
>
Disable Two-Factor Authentication
</VBtn>
</div>
</VCardText>
</VCard>
</VCol>
<!-- Recent Devices (placeholder) -->
<VCol cols="12">
<VCard title="Recent Devices">
<VDivider />
<VCardText>
<VTable>
<thead>
<tr>
<th>BROWSER</th>
<th>DEVICE</th>
<th>LOCATION</th>
<th>RECENT ACTIVITY</th>
</tr>
</thead>
<tbody>
<tr>
<td colspan="4" class="text-center text-medium-emphasis pa-6">
Session tracking will be available soon.
</td>
</tr>
</tbody>
</VTable>
</VCardText>
</VCard>
</VCol>
</VRow>
</template>
<style lang="scss" scoped>
.card-list {
--v-card-list-gap: 16px;
}
</style>

View File

@@ -1,83 +1,87 @@
<script lang="ts" setup>
import { useForm } from '@inertiajs/vue3'
import { ref, computed } from 'vue'
import AccountLayout from '@/Layouts/AccountLayout.vue'
import AppTextField from '@/Components/app-form-elements/AppTextField.vue'
import type { User } from '@/types'
import AccountTab from '@/Pages/Profile/AccountTab.vue'
import SecurityTab from '@/Pages/Profile/SecurityTab.vue'
import BillingTab from '@/Pages/Profile/BillingTab.vue'
import type { User, UserProfile } from '@/types'
interface Props {
user: User & { phone?: string; company?: string }
user: User
profile: UserProfile | null
twoFactorEnabled: boolean
}
defineOptions({ layout: AccountLayout })
const props = defineProps<Props>()
const form = useForm({
name: props.user.name,
phone: props.user.phone || '',
company: props.user.company || '',
const activeTab = ref<string>('account')
interface TabItem {
title: string
icon: string
value: string
}
const tabs: TabItem[] = [
{ title: 'Account', icon: 'tabler-user', value: 'account' },
{ title: 'Security', icon: 'tabler-lock', value: 'security' },
{ title: 'Billing', icon: 'tabler-file-text', value: 'billing' },
]
const firstName = computed<string>(() => {
const parts = props.user.name?.split(' ') ?? []
return parts[0] ?? ''
})
const submit = (): void => {
form.put('/profile')
}
const lastName = computed<string>(() => {
const parts = props.user.name?.split(' ') ?? []
return parts.slice(1).join(' ') ?? ''
})
</script>
<template>
<div style="max-width: 600px;">
<div class="text-h4 font-weight-bold mb-6">Profile Settings</div>
<div>
<VTabs
v-model="activeTab"
class="v-tabs-pill"
>
<VTab
v-for="tab in tabs"
:key="tab.value"
:value="tab.value"
>
<VIcon
size="20"
start
:icon="tab.icon"
/>
{{ tab.title }}
</VTab>
</VTabs>
<VCard>
<VCardText>
<VForm @submit.prevent="submit">
<VRow>
<VCol cols="12">
<AppTextField
v-model="form.name"
label="Name"
type="text"
required
:error-messages="form.errors.name ? [form.errors.name] : []"
/>
</VCol>
<VWindow
v-model="activeTab"
class="mt-6 disable-tab-transition"
:touch="false"
>
<VWindowItem value="account">
<AccountTab
:user="user"
:profile="profile"
:first-name="firstName"
:last-name="lastName"
/>
</VWindowItem>
<VCol cols="12">
<AppTextField
:model-value="user.email"
label="Email"
type="email"
disabled
/>
</VCol>
<VWindowItem value="security">
<SecurityTab :two-factor-enabled="twoFactorEnabled" />
</VWindowItem>
<VCol cols="12">
<AppTextField
v-model="form.phone"
label="Phone"
type="tel"
/>
</VCol>
<VCol cols="12">
<AppTextField
v-model="form.company"
label="Company"
type="text"
/>
</VCol>
<VCol cols="12">
<VBtn
type="submit"
:loading="form.processing"
:disabled="form.processing"
>
Save Changes
</VBtn>
</VCol>
</VRow>
</VForm>
</VCardText>
</VCard>
<VWindowItem value="billing">
<BillingTab :profile="profile" />
</VWindowItem>
</VWindow>
</div>
</template>