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:
@@ -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';
|
||||
}
|
||||
|
||||
28
website/resources/js/Components/Button.vue
Normal file
28
website/resources/js/Components/Button.vue
Normal 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>
|
||||
12
website/resources/js/Components/Card.vue
Normal file
12
website/resources/js/Components/Card.vue
Normal 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>
|
||||
16
website/resources/js/Components/FlashMessages.vue
Normal file
16
website/resources/js/Components/FlashMessages.vue
Normal 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>
|
||||
22
website/resources/js/Components/NavLink.vue
Normal file
22
website/resources/js/Components/NavLink.vue
Normal 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>
|
||||
46
website/resources/js/Layouts/AdminLayout.vue
Normal file
46
website/resources/js/Layouts/AdminLayout.vue
Normal 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>
|
||||
46
website/resources/js/Layouts/AppLayout.vue
Normal file
46
website/resources/js/Layouts/AppLayout.vue
Normal 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>
|
||||
16
website/resources/js/Layouts/AuthLayout.vue
Normal file
16
website/resources/js/Layouts/AuthLayout.vue
Normal 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>
|
||||
34
website/resources/js/Pages/Admin/Dashboard.vue
Normal file
34
website/resources/js/Pages/Admin/Dashboard.vue
Normal 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>
|
||||
44
website/resources/js/Pages/Auth/ConfirmPassword.vue
Normal file
44
website/resources/js/Pages/Auth/ConfirmPassword.vue
Normal 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>
|
||||
52
website/resources/js/Pages/Auth/ForgotPassword.vue
Normal file
52
website/resources/js/Pages/Auth/ForgotPassword.vue
Normal 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>
|
||||
69
website/resources/js/Pages/Auth/Login.vue
Normal file
69
website/resources/js/Pages/Auth/Login.vue
Normal 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>
|
||||
85
website/resources/js/Pages/Auth/Register.vue
Normal file
85
website/resources/js/Pages/Auth/Register.vue
Normal 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>
|
||||
73
website/resources/js/Pages/Auth/ResetPassword.vue
Normal file
73
website/resources/js/Pages/Auth/ResetPassword.vue
Normal 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>
|
||||
81
website/resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal file
81
website/resources/js/Pages/Auth/TwoFactorChallenge.vue
Normal 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>
|
||||
37
website/resources/js/Pages/Auth/VerifyEmail.vue
Normal file
37
website/resources/js/Pages/Auth/VerifyEmail.vue
Normal 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>
|
||||
35
website/resources/js/Pages/Dashboard.vue
Normal file
35
website/resources/js/Pages/Dashboard.vue
Normal 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>
|
||||
35
website/resources/js/Pages/Marketing/Home.vue
Normal file
35
website/resources/js/Pages/Marketing/Home.vue
Normal 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>
|
||||
81
website/resources/js/Pages/Profile/Show.vue
Normal file
81
website/resources/js/Pages/Profile/Show.vue
Normal 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>
|
||||
134
website/resources/js/Pages/Profile/TwoFactorSetup.vue
Normal file
134
website/resources/js/Pages/Profile/TwoFactorSetup.vue
Normal 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>
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
|
||||
18
website/resources/views/app.blade.php
Normal file
18
website/resources/views/app.blade.php
Normal 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>
|
||||
Reference in New Issue
Block a user