Implement Phase 1: Foundation & Core Setup

Complete foundation for the EZSCALE billing platform replacing WHMCS:

- Install Composer deps (Fortify, Passport, Cashier, PayPal, Spatie Permissions, Inertia)
- Install Vue 3 + Inertia.js with Vite, 3 layouts (App, Auth, Admin)
- Configure subdomain routing (marketing, account, admin) with domain-based route files
- Create 30 database migrations (15 custom tables + package defaults)
- Create 14 Eloquent models with relationships, factories, and encrypted casts
- Set up Fortify auth with 7 Vue pages (Login, Register, ForgotPassword, ResetPassword, VerifyEmail, ConfirmPassword, TwoFactorChallenge)
- Add 2FA TOTP setup page with QR code and recovery codes
- Configure middleware (Inertia, Spatie roles/permissions, EnsureUserNotSuspended)
- Create seeders for roles/permissions, sample plans, and admin user
- Build dashboard controllers and Vue pages for customer and admin panels
- Add 4 shared Vue components (Card, Button, NavLink, FlashMessages)
- Generate Passport OAuth2 keys for future SSO/API use
- Write 24 Pest tests (auth, role-based access, models) — all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Claude Dev
2026-02-09 02:50:46 -05:00
parent cf7669f270
commit 26704f9721
130 changed files with 6862 additions and 230 deletions

View File

@@ -4,8 +4,9 @@
@source '../../storage/framework/views/*.php';
@source '../**/*.blade.php';
@source '../**/*.js';
@source '../**/*.vue';
@theme {
--font-sans: 'Instrument Sans', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
--font-sans: 'Inter', ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji',
'Segoe UI Symbol', 'Noto Color Emoji';
}

View File

@@ -0,0 +1,28 @@
<script setup>
defineProps({
type: {
type: String,
default: 'submit',
},
variant: {
type: String,
default: 'primary',
},
disabled: Boolean,
});
</script>
<template>
<button
:type="type"
:disabled="disabled"
:class="[
'px-4 py-2 text-sm font-medium rounded-md disabled:opacity-50',
variant === 'primary' && 'bg-blue-600 text-white hover:bg-blue-700',
variant === 'secondary' && 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50',
variant === 'danger' && 'bg-red-600 text-white hover:bg-red-700',
]"
>
<slot />
</button>
</template>

View File

@@ -0,0 +1,12 @@
<script setup>
defineProps({
title: String,
});
</script>
<template>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 v-if="title" class="text-sm font-medium text-gray-500 mb-2">{{ title }}</h3>
<slot />
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
import { usePage } from '@inertiajs/vue3';
import { computed } from 'vue';
const page = usePage();
const flash = computed(() => page.props.flash || {});
</script>
<template>
<div v-if="flash.success" class="mb-4 rounded-md bg-green-50 p-4">
<p class="text-sm font-medium text-green-800">{{ flash.success }}</p>
</div>
<div v-if="flash.error" class="mb-4 rounded-md bg-red-50 p-4">
<p class="text-sm font-medium text-red-800">{{ flash.error }}</p>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
import { Link } from '@inertiajs/vue3';
defineProps({
href: String,
active: Boolean,
});
</script>
<template>
<Link
:href="href"
:class="[
'px-3 py-2 rounded-md text-sm font-medium',
active
? 'bg-gray-100 text-gray-900'
: 'text-gray-700 hover:text-gray-900 hover:bg-gray-100',
]"
>
<slot />
</Link>
</template>

View File

@@ -0,0 +1,46 @@
<script setup>
import { Link, usePage } from '@inertiajs/vue3';
const page = usePage();
const user = page.props.auth?.user;
</script>
<template>
<div class="min-h-screen bg-gray-900">
<nav class="bg-gray-800 border-b border-gray-700">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<Link href="/dashboard" class="text-xl font-bold text-white">
EZSCALE <span class="text-xs font-normal text-gray-400">Admin</span>
</Link>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<Link
href="/dashboard"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-300 hover:text-white hover:bg-gray-700"
>
Dashboard
</Link>
</div>
</div>
<div class="flex items-center space-x-4">
<span v-if="user" class="text-sm text-gray-300">{{ user.name }}</span>
<Link
v-if="user"
href="/logout"
method="post"
as="button"
class="text-sm text-gray-400 hover:text-white"
>
Log out
</Link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,46 @@
<script setup>
import { Link, usePage } from '@inertiajs/vue3';
const page = usePage();
const user = page.props.auth?.user;
</script>
<template>
<div class="min-h-screen bg-gray-50">
<nav class="bg-white border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16">
<div class="flex items-center">
<Link href="/dashboard" class="text-xl font-bold text-gray-900">
EZSCALE
</Link>
<div class="hidden sm:ml-8 sm:flex sm:space-x-4">
<Link
href="/dashboard"
class="px-3 py-2 rounded-md text-sm font-medium text-gray-700 hover:text-gray-900 hover:bg-gray-100"
>
Dashboard
</Link>
</div>
</div>
<div class="flex items-center space-x-4">
<span v-if="user" class="text-sm text-gray-600">{{ user.name }}</span>
<Link
v-if="user"
href="/logout"
method="post"
as="button"
class="text-sm text-gray-600 hover:text-gray-900"
>
Log out
</Link>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto py-6 px-4 sm:px-6 lg:px-8">
<slot />
</main>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
</script>
<template>
<div class="min-h-screen flex flex-col items-center justify-center bg-gray-50 py-12 px-4 sm:px-6 lg:px-8">
<div class="w-full max-w-md">
<div class="text-center mb-8">
<h1 class="text-3xl font-bold text-gray-900">EZSCALE</h1>
<p class="mt-2 text-sm text-gray-600">Cloud Hosting Platform</p>
</div>
<div class="bg-white shadow-sm rounded-lg border border-gray-200 p-8">
<slot />
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,34 @@
<script setup>
import AdminLayout from '@/Layouts/AdminLayout.vue';
defineOptions({ layout: AdminLayout });
defineProps({
totalCustomers: Number,
totalServices: Number,
activeServices: Number,
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-white mb-6">Admin Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Total Customers</h3>
<p class="mt-2 text-3xl font-bold text-white">{{ totalCustomers }}</p>
</div>
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Total Services</h3>
<p class="mt-2 text-3xl font-bold text-white">{{ totalServices }}</p>
</div>
<div class="bg-gray-800 rounded-lg border border-gray-700 p-6">
<h3 class="text-sm font-medium text-gray-400">Active Services</h3>
<p class="mt-2 text-3xl font-bold text-green-400">{{ activeServices }}</p>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,44 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
password: '',
});
const submit = () => {
form.post('/user/confirm-password', {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-4">Confirm your password</h2>
<p class="text-sm text-gray-600 mb-6">Please confirm your password before continuing.</p>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Confirm
</button>
</form>
</template>

View File

@@ -0,0 +1,52 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
defineProps({
status: String,
});
const form = useForm({
email: '',
});
const submit = () => {
form.post('/forgot-password');
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-4">Reset your password</h2>
<p class="text-sm text-gray-600 mb-6">Enter your email and we'll send you a reset link.</p>
<div v-if="status" class="mb-4 text-sm font-medium text-green-600">{{ status }}</div>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Send reset link
</button>
<p class="text-center text-sm text-gray-600">
<a href="/login" class="text-blue-600 hover:text-blue-500">Back to login</a>
</p>
</form>
</template>

View File

@@ -0,0 +1,69 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
email: '',
password: '',
remember: false,
});
const submit = () => {
form.post('/login', {
onFinish: () => form.reset('password'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-6">Sign in to your account</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<div class="flex items-center justify-between">
<label class="flex items-center">
<input v-model="form.remember" type="checkbox" class="rounded border-gray-300 text-blue-600" />
<span class="ml-2 text-sm text-gray-600">Remember me</span>
</label>
<a href="/forgot-password" class="text-sm text-blue-600 hover:text-blue-500">Forgot password?</a>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Sign in
</button>
<p class="text-center text-sm text-gray-600">
Don't have an account? <a href="/register" class="text-blue-600 hover:text-blue-500">Sign up</a>
</p>
</form>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const form = useForm({
name: '',
email: '',
password: '',
password_confirmation: '',
});
const submit = () => {
form.post('/register', {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-6">Create an account</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input
id="name"
v-model="form.name"
type="text"
required
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Confirm Password</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Create account
</button>
<p class="text-center text-sm text-gray-600">
Already have an account? <a href="/login" class="text-blue-600 hover:text-blue-500">Sign in</a>
</p>
</form>
</template>

View File

@@ -0,0 +1,73 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const props = defineProps({
token: String,
email: String,
});
const form = useForm({
token: props.token,
email: props.email,
password: '',
password_confirmation: '',
});
const submit = () => {
form.post('/reset-password', {
onFinish: () => form.reset('password', 'password_confirmation'),
});
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-6">Set new password</h2>
<form @submit.prevent="submit" class="space-y-4">
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
v-model="form.email"
type="email"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.email" class="mt-1 text-sm text-red-600">{{ form.errors.email }}</p>
</div>
<div>
<label for="password" class="block text-sm font-medium text-gray-700">New Password</label>
<input
id="password"
v-model="form.password"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.password" class="mt-1 text-sm text-red-600">{{ form.errors.password }}</p>
</div>
<div>
<label for="password_confirmation" class="block text-sm font-medium text-gray-700">Confirm Password</label>
<input
id="password_confirmation"
v-model="form.password_confirmation"
type="password"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Reset password
</button>
</form>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { ref } from 'vue';
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
const useRecovery = ref(false);
const form = useForm({
code: '',
recovery_code: '',
});
const submit = () => {
form.post('/two-factor-challenge', {
onFinish: () => form.reset(),
});
};
const toggleRecovery = () => {
useRecovery.value = !useRecovery.value;
form.reset();
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-4">Two-Factor Authentication</h2>
<p class="text-sm text-gray-600 mb-6">
<template v-if="!useRecovery">
Enter the authentication code from your authenticator app.
</template>
<template v-else>
Enter one of your emergency recovery codes.
</template>
</p>
<form @submit.prevent="submit" class="space-y-4">
<div v-if="!useRecovery">
<label for="code" class="block text-sm font-medium text-gray-700">Code</label>
<input
id="code"
v-model="form.code"
type="text"
inputmode="numeric"
autofocus
autocomplete="one-time-code"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.code" class="mt-1 text-sm text-red-600">{{ form.errors.code }}</p>
</div>
<div v-else>
<label for="recovery_code" class="block text-sm font-medium text-gray-700">Recovery Code</label>
<input
id="recovery_code"
v-model="form.recovery_code"
type="text"
autofocus
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.recovery_code" class="mt-1 text-sm text-red-600">{{ form.errors.recovery_code }}</p>
</div>
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Verify
</button>
<button
type="button"
@click="toggleRecovery"
class="w-full text-center text-sm text-gray-600 hover:text-gray-900"
>
{{ useRecovery ? 'Use authentication code' : 'Use a recovery code' }}
</button>
</form>
</template>

View File

@@ -0,0 +1,37 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AuthLayout from '@/Layouts/AuthLayout.vue';
defineOptions({ layout: AuthLayout });
defineProps({
status: String,
});
const form = useForm({});
const submit = () => {
form.post('/email/verification-notification');
};
</script>
<template>
<h2 class="text-xl font-semibold text-gray-900 mb-4">Verify your email</h2>
<p class="text-sm text-gray-600 mb-6">
We've sent a verification link to your email. Please check your inbox and click the link to verify.
</p>
<div v-if="status === 'verification-link-sent'" class="mb-4 text-sm font-medium text-green-600">
A new verification link has been sent to your email address.
</div>
<form @submit.prevent="submit">
<button
type="submit"
:disabled="form.processing"
class="w-full flex justify-center py-2 px-4 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
>
Resend verification email
</button>
</form>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
defineProps({
servicesCount: Number,
activeServicesCount: Number,
});
</script>
<template>
<div>
<h1 class="text-2xl font-bold text-gray-900 mb-6">Dashboard</h1>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-500">Total Services</h3>
<p class="mt-2 text-3xl font-bold text-gray-900">{{ servicesCount }}</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-500">Active Services</h3>
<p class="mt-2 text-3xl font-bold text-green-600">{{ activeServicesCount }}</p>
</div>
<div class="bg-white rounded-lg border border-gray-200 shadow-sm p-6">
<h3 class="text-sm font-medium text-gray-500">Quick Actions</h3>
<div class="mt-3 space-y-2">
<a href="/profile" class="block text-sm text-blue-600 hover:text-blue-500">Edit Profile</a>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,35 @@
<script setup>
</script>
<template>
<div class="min-h-screen bg-white">
<nav class="border-b border-gray-200">
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="flex justify-between h-16 items-center">
<span class="text-xl font-bold text-gray-900">EZSCALE</span>
<div class="space-x-4">
<a href="/login" class="text-sm text-gray-600 hover:text-gray-900">Sign in</a>
<a href="/register" class="text-sm px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700">Get Started</a>
</div>
</div>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-24">
<div class="text-center">
<h1 class="text-4xl font-bold text-gray-900 sm:text-5xl md:text-6xl">
Cloud Hosting
<span class="text-blue-600">Made Simple</span>
</h1>
<p class="mt-6 max-w-2xl mx-auto text-xl text-gray-500">
VPS, Dedicated Servers, Web Hosting, and Game Servers. Powered by EZSCALE.
</p>
<div class="mt-10">
<a href="/register" class="px-8 py-3 bg-blue-600 text-white font-medium rounded-md hover:bg-blue-700 text-lg">
Start Free Trial
</a>
</div>
</div>
</main>
</div>
</template>

View File

@@ -0,0 +1,81 @@
<script setup>
import { useForm } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const props = defineProps({
user: Object,
});
const form = useForm({
name: props.user.name,
phone: props.user.phone || '',
company: props.user.company || '',
});
const submit = () => {
form.put('/profile');
};
</script>
<template>
<div class="max-w-2xl">
<h1 class="text-2xl font-bold text-gray-900 mb-6">Profile Settings</h1>
<form @submit.prevent="submit" class="bg-white rounded-lg border border-gray-200 shadow-sm p-6 space-y-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700">Name</label>
<input
id="name"
v-model="form.name"
type="text"
required
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="form.errors.name" class="mt-1 text-sm text-red-600">{{ form.errors.name }}</p>
</div>
<div>
<label for="email" class="block text-sm font-medium text-gray-700">Email</label>
<input
id="email"
:value="user.email"
type="email"
disabled
class="mt-1 block w-full rounded-md border-gray-200 bg-gray-50 shadow-sm px-3 py-2 border text-gray-500"
/>
</div>
<div>
<label for="phone" class="block text-sm font-medium text-gray-700">Phone</label>
<input
id="phone"
v-model="form.phone"
type="tel"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<div>
<label for="company" class="block text-sm font-medium text-gray-700">Company</label>
<input
id="company"
v-model="form.company"
type="text"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
</div>
<div class="pt-2">
<button
type="submit"
:disabled="form.processing"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Save Changes
</button>
</div>
</form>
</div>
</template>

View File

@@ -0,0 +1,134 @@
<script setup>
import { ref } from 'vue';
import { useForm, usePage, router } from '@inertiajs/vue3';
import AppLayout from '@/Layouts/AppLayout.vue';
defineOptions({ layout: AppLayout });
const page = usePage();
const enabling = ref(false);
const confirming = ref(false);
const disabling = ref(false);
const qrCode = ref('');
const recoveryCodes = ref([]);
const confirmationForm = useForm({
code: '',
});
const enableTwoFactor = () => {
enabling.value = true;
router.post('/user/two-factor-authentication', {}, {
preserveScroll: true,
onSuccess: () => {
confirming.value = true;
showQrCode();
showRecoveryCodes();
},
onFinish: () => {
enabling.value = false;
},
});
};
const showQrCode = () => {
fetch('/user/two-factor-qr-code')
.then(r => r.json())
.then(data => { qrCode.value = data.svg; });
};
const showRecoveryCodes = () => {
fetch('/user/two-factor-recovery-codes')
.then(r => r.json())
.then(data => { recoveryCodes.value = data; });
};
const confirmTwoFactor = () => {
confirmationForm.post('/user/confirmed-two-factor-authentication', {
preserveScroll: true,
onSuccess: () => {
confirming.value = false;
},
});
};
const disableTwoFactor = () => {
disabling.value = true;
router.delete('/user/two-factor-authentication', {
preserveScroll: true,
onSuccess: () => {
qrCode.value = '';
recoveryCodes.value = [];
},
onFinish: () => {
disabling.value = false;
},
});
};
</script>
<template>
<div class="max-w-2xl">
<h2 class="text-lg font-semibold text-gray-900 mb-4">Two-Factor Authentication</h2>
<p class="text-sm text-gray-600 mb-6">
Add an extra layer of security to your account using a TOTP authenticator app.
</p>
<div v-if="!page.props.auth?.user?.two_factor_enabled">
<button
@click="enableTwoFactor"
:disabled="enabling"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Enable Two-Factor Authentication
</button>
</div>
<div v-if="confirming" class="mt-6">
<p class="text-sm text-gray-600 mb-4">
Scan this QR code with your authenticator app, then enter the code below to confirm.
</p>
<div v-if="qrCode" v-html="qrCode" class="mb-4"></div>
<form @submit.prevent="confirmTwoFactor" class="space-y-4 max-w-xs">
<div>
<label for="code" class="block text-sm font-medium text-gray-700">Confirmation Code</label>
<input
id="code"
v-model="confirmationForm.code"
type="text"
inputmode="numeric"
class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-blue-500 focus:ring-blue-500 px-3 py-2 border"
/>
<p v-if="confirmationForm.errors.code" class="mt-1 text-sm text-red-600">{{ confirmationForm.errors.code }}</p>
</div>
<button
type="submit"
:disabled="confirmationForm.processing"
class="px-4 py-2 bg-blue-600 text-white text-sm font-medium rounded-md hover:bg-blue-700 disabled:opacity-50"
>
Confirm
</button>
</form>
</div>
<div v-if="recoveryCodes.length > 0 && !confirming" class="mt-6">
<h3 class="text-sm font-semibold text-gray-900 mb-2">Recovery Codes</h3>
<p class="text-sm text-gray-600 mb-3">Store these codes in a safe place. They can be used to access your account if you lose your authenticator device.</p>
<div class="bg-gray-100 rounded-md p-4 font-mono text-sm">
<div v-for="code in recoveryCodes" :key="code">{{ code }}</div>
</div>
</div>
<div v-if="page.props.auth?.user?.two_factor_enabled && !confirming" class="mt-6">
<p class="text-sm text-green-600 mb-4">Two-factor authentication is enabled.</p>
<button
@click="disableTwoFactor"
:disabled="disabling"
class="px-4 py-2 bg-red-600 text-white text-sm font-medium rounded-md hover:bg-red-700 disabled:opacity-50"
>
Disable Two-Factor Authentication
</button>
</div>
</div>
</template>

View File

@@ -1 +1,24 @@
import './bootstrap';
import '../css/app.css';
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { resolvePageComponent } from 'laravel-vite-plugin/inertia-helpers';
const appName = import.meta.env.VITE_APP_NAME || 'EZSCALE';
createInertiaApp({
title: (title) => title ? `${title} - ${appName}` : appName,
resolve: (name) => resolvePageComponent(
`./Pages/${name}.vue`,
import.meta.glob('./Pages/**/*.vue'),
),
setup({ el, App, props, plugin }) {
return createApp({ render: () => h(App, props) })
.use(plugin)
.mount(el);
},
progress: {
color: '#4B5563',
},
});

View File

@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html lang="{{ str_replace('_', '-', app()->getLocale()) }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title inertia>{{ config('app.name', 'EZSCALE') }}</title>
<link rel="preconnect" href="https://fonts.bunny.net">
<link href="https://fonts.bunny.net/css?family=inter:400,500,600,700" rel="stylesheet" />
@vite(['resources/js/app.js'])
@inertiaHead
</head>
<body class="font-sans antialiased">
@inertia
</body>
</html>