48 KiB
Settings Vue Migration Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Migrate the Livewire settings pages (Profile, Security, Appearance) into the Vue SPA and add a user avatar dropdown to the dashboard top nav.
Architecture: Three new Vue settings views nested under SettingsLayout.vue at /dashboard/settings/*. UserController gains updateProfile, updatePassword, and deleteAccount actions. Fortify 2FA web routes are called directly from Security.vue using a separate axios instance. Livewire settings components and routes are removed after Vue implementation is verified.
Tech Stack: Vue 3 (Composition API), Vue Router 4, Alpine.js (dropdown), axios, Laravel Sanctum, Fortify 2FA routes, Pest
File Map
New files:
resources/js/views/dashboard/settings/SettingsLayout.vue— Sub-nav wrapper (Profile / Security / Appearance)resources/js/views/dashboard/settings/Profile.vue— Name/email form + delete account modalresources/js/views/dashboard/settings/Security.vue— Password form + 2FA managementresources/js/views/dashboard/settings/Appearance.vue— Theme toggle (localStorage)
Modified files:
tests/Feature/Api/UserControllerTest.php— New tests for the three new API actionsapp/Http/Controllers/Api/UserController.php— AddupdateProfile,updatePassword,deleteAccountroutes/api.php— Register 3 new routesresources/js/composables/useAuth.js— Addlogout,updateProfile,updatePassword,deleteAccountresources/js/views/dashboard/DashboardLayout.vue— Replace email text with user avatar dropdownresources/js/router/index.js— Add/dashboard/settingsnested routes
Deleted in Task 8:
routes/settings.php+requireline fromroutes/web.phpapp/Livewire/Settings/(5 files + subdirectory)resources/views/livewire/settings/(5 files + subdirectory)resources/views/components/settings/layout.blade.phptests/Feature/Settings/SecurityTest.phpandProfileUpdateTest.php
NOT deleted: app/Concerns/ProfileValidationRules.php and PasswordValidationRules.php — still used by app/Actions/Fortify/CreateNewUser.php and app/Actions/Fortify/ResetUserPassword.php.
Task 1: Backend API endpoints
Files:
-
Modify:
tests/Feature/Api/UserControllerTest.php -
Modify:
app/Http/Controllers/Api/UserController.php -
Modify:
routes/api.php -
Step 1: Add
Hashimport to the top oftests/Feature/Api/UserControllerTest.php(after<?php):
use Illuminate\Support\Facades\Hash;
- Step 2: Append failing tests to
tests/Feature/Api/UserControllerTest.php:
// --- Profile update ---
it('updates user profile name and email', function (): void {
$user = User::factory()->create(['name' => 'Old Name', 'email' => 'old@example.com']);
Sanctum::actingAs($user);
$this->putJson('/api/user/profile', ['name' => 'New Name', 'email' => 'new@example.com'])
->assertOk()
->assertJsonFragment(['name' => 'New Name', 'email' => 'new@example.com']);
expect($user->fresh()->name)->toBe('New Name');
expect($user->fresh()->email)->toBe('new@example.com');
});
it('nulls email_verified_at when email changes', function (): void {
$user = User::factory()->create(['email_verified_at' => now()]);
Sanctum::actingAs($user);
$this->putJson('/api/user/profile', ['name' => $user->name, 'email' => 'changed@example.com'])
->assertOk();
expect($user->fresh()->email_verified_at)->toBeNull();
});
it('does not null email_verified_at when email is unchanged', function (): void {
$user = User::factory()->create(['email_verified_at' => now()]);
Sanctum::actingAs($user);
$this->putJson('/api/user/profile', ['name' => 'New Name', 'email' => $user->email])
->assertOk();
expect($user->fresh()->email_verified_at)->not->toBeNull();
});
it('rejects profile update with duplicate email', function (): void {
User::factory()->create(['email' => 'taken@example.com']);
$user = User::factory()->create();
Sanctum::actingAs($user);
$this->putJson('/api/user/profile', ['name' => 'Name', 'email' => 'taken@example.com'])
->assertUnprocessable()
->assertJsonValidationErrors(['email']);
});
it('rejects profile update with missing name', function (): void {
$user = User::factory()->create();
Sanctum::actingAs($user);
$this->putJson('/api/user/profile', ['email' => 'new@example.com'])
->assertUnprocessable()
->assertJsonValidationErrors(['name']);
});
// --- Password update ---
it('updates user password', function (): void {
$user = User::factory()->create(['password' => Hash::make('old-password')]);
Sanctum::actingAs($user);
$this->putJson('/api/user/password', [
'current_password' => 'old-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])->assertOk();
expect(Hash::check('new-password', $user->fresh()->password))->toBeTrue();
});
it('rejects wrong current password', function (): void {
$user = User::factory()->create(['password' => Hash::make('correct-password')]);
Sanctum::actingAs($user);
$this->putJson('/api/user/password', [
'current_password' => 'wrong-password',
'password' => 'new-password',
'password_confirmation' => 'new-password',
])->assertUnprocessable()
->assertJsonValidationErrors(['current_password']);
});
it('rejects mismatched password confirmation', function (): void {
$user = User::factory()->create(['password' => Hash::make('correct-password')]);
Sanctum::actingAs($user);
$this->putJson('/api/user/password', [
'current_password' => 'correct-password',
'password' => 'new-password',
'password_confirmation' => 'different',
])->assertUnprocessable()
->assertJsonValidationErrors(['password']);
});
// --- Delete account ---
it('deletes user account with correct password', function (): void {
$user = User::factory()->create(['password' => Hash::make('my-password')]);
Sanctum::actingAs($user);
$this->deleteJson('/api/user', ['password' => 'my-password'])
->assertNoContent();
expect(User::find($user->id))->toBeNull();
});
it('revokes sanctum tokens on account deletion', function (): void {
$user = User::factory()->create(['password' => Hash::make('my-password')]);
$user->createToken('test');
Sanctum::actingAs($user);
$this->deleteJson('/api/user', ['password' => 'my-password'])
->assertNoContent();
expect($user->tokens()->count())->toBe(0);
});
it('rejects account deletion with wrong password', function (): void {
$user = User::factory()->create(['password' => Hash::make('correct-password')]);
Sanctum::actingAs($user);
$this->deleteJson('/api/user', ['password' => 'wrong-password'])
->assertUnprocessable()
->assertJsonValidationErrors(['password']);
expect(User::find($user->id))->not->toBeNull();
});
it('rejects unauthenticated requests to profile, password, and delete endpoints', function (): void {
$this->putJson('/api/user/profile', [])->assertUnauthorized();
$this->putJson('/api/user/password', [])->assertUnauthorized();
$this->deleteJson('/api/user', [])->assertUnauthorized();
});
- Step 3: Run tests to confirm they fail
php artisan test --compact tests/Feature/Api/UserControllerTest.php --timeout=10
Expected: The new tests FAIL (routes not found / 404).
- Step 4: Add the three methods to
app/Http/Controllers/Api/UserController.php.
Add these imports to the existing import block:
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
use App\Models\User;
Add these methods inside the UserController class:
public function updateProfile(Request $request): JsonResponse
{
$validated = $request->validate([
'name' => ['required', 'string', 'max:255'],
'email' => ['required', 'string', 'email', 'max:255', Rule::unique(User::class)->ignore($request->user()->id)],
]);
$user = $request->user();
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
return response()->json($user->fresh());
}
public function updatePassword(Request $request): JsonResponse
{
$request->validate([
'current_password' => ['required', 'string'],
'password' => ['required', 'string', Password::defaults(), 'confirmed'],
]);
if (! Hash::check($request->string('current_password'), $request->user()->password)) {
throw ValidationException::withMessages([
'current_password' => [__('The provided password does not match your current password.')],
]);
}
$request->user()->update(['password' => $request->string('password')]);
return response()->json(['message' => 'Password updated.']);
}
public function deleteAccount(Request $request): \Illuminate\Http\Response
{
$request->validate(['password' => ['required', 'string']]);
if (! Hash::check($request->string('password'), $request->user()->password)) {
throw ValidationException::withMessages([
'password' => [__('The provided password does not match your current password.')],
]);
}
$user = $request->user();
$user->tokens()->delete();
$user->delete();
return response()->noContent();
}
Note: Rule is already imported (use Illuminate\Validation\Rule;) and User is available in the namespace — add use App\Models\User; only if not already present.
- Step 5: Register the three routes in
routes/api.phpinside theauth:sanctumgroup, after the saved-stations routes:
Route::put('/user/profile', [UserController::class, 'updateProfile']);
Route::put('/user/password', [UserController::class, 'updatePassword']);
Route::delete('/user', [UserController::class, 'deleteAccount']);
- Step 6: Run Pint
vendor/bin/pint --dirty --format agent
- Step 7: Run tests to confirm they pass
php artisan test --compact tests/Feature/Api/UserControllerTest.php --timeout=10
Expected: All PASS.
- Step 8: Commit
git add app/Http/Controllers/Api/UserController.php routes/api.php tests/Feature/Api/UserControllerTest.php
git commit -m "feat: add updateProfile, updatePassword, deleteAccount API endpoints"
Task 2: useAuth composable — add logout + settings methods
Files:
-
Modify:
resources/js/composables/useAuth.js -
Step 1: Replace the file content of
resources/js/composables/useAuth.js:
import { ref, computed } from 'vue'
import api from '../axios.js'
const user = ref(null)
const loading = ref(false)
const fetched = ref(false)
export function useAuth() {
const isAuthenticated = computed(() => user.value !== null)
const userTier = computed(() => {
if (!user.value) {
return 'guest'
}
return user.value.tier ?? 'free'
})
const isPaidTier = computed(() => {
return ['basic', 'plus', 'pro'].includes(userTier.value)
})
async function fetchUser() {
if (fetched.value) {
return
}
loading.value = true
try {
const response = await api.get('/auth/me')
user.value = response.data
} catch {
user.value = null
} finally {
loading.value = false
fetched.value = true
}
}
function clearUser() {
user.value = null
fetched.value = false
}
async function logout() {
try {
await api.post('/auth/logout')
} finally {
clearUser()
}
}
async function updateProfile(data) {
const response = await api.put('/user/profile', data)
user.value = response.data
}
async function updatePassword(data) {
await api.put('/user/password', data)
}
async function deleteAccount(password) {
await api.delete('/user', { data: { password } })
clearUser()
}
return {
user,
loading,
isAuthenticated,
userTier,
isPaidTier,
fetchUser,
clearUser,
logout,
updateProfile,
updatePassword,
deleteAccount,
}
}
- Step 2: Commit
git add resources/js/composables/useAuth.js
git commit -m "feat: add logout, updateProfile, updatePassword, deleteAccount to useAuth"
Task 3: DashboardLayout — user avatar dropdown
Files:
-
Modify:
resources/js/views/dashboard/DashboardLayout.vue -
Step 1: Replace the file content of
resources/js/views/dashboard/DashboardLayout.vue:
<template>
<div class="min-h-screen bg-[#f5ede5] flex flex-col">
<!-- Top nav -->
<nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4">
<div class="max-w-7xl mx-auto flex items-center justify-between">
<RouterLink to="/" class="flex items-center gap-3">
<div class="w-10 h-10 rounded-lg bg-[#bb5b3e] flex items-center justify-center shadow-md">
<iconify-icon icon="lucide:fuel" class="text-white text-xl"></iconify-icon>
</div>
<span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
</RouterLink>
<div class="flex items-center gap-4">
<RouterLink to="/" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">
← Find fuel
</RouterLink>
<!-- User dropdown -->
<div x-data="{ open: false }" class="relative">
<button
@click="open = !open"
class="w-9 h-9 rounded-full bg-[#bb5b3e] flex items-center justify-center text-white text-sm font-black hover:bg-[#a34a31] transition-colors"
>
{{ userInitials }}
</button>
<div
x-show="open"
@click.away="open = false"
x-transition
class="absolute right-0 top-full mt-2 w-64 bg-white border border-[#e5ded7] rounded-2xl shadow-lg overflow-hidden z-50"
style="display: none"
>
<div class="px-4 py-3 border-b border-[#e5ded7]">
<p class="text-sm font-black text-[#4a3f3b]">{{ user?.name }}</p>
<p class="text-xs text-[#89726c] truncate">{{ user?.email }}</p>
</div>
<div class="py-1">
<RouterLink
to="/dashboard/settings"
@click="open = false"
class="flex items-center gap-3 px-4 py-2.5 text-sm font-bold text-[#89726c] hover:bg-[#faf6f3] hover:text-[#4a3f3b] transition-colors"
>
<iconify-icon icon="lucide:settings"></iconify-icon>
Settings
</RouterLink>
<button
@click="handleLogout"
class="w-full flex items-center gap-3 px-4 py-2.5 text-sm font-bold text-[#89726c] hover:bg-[#faf6f3] hover:text-[#4a3f3b] transition-colors"
>
<iconify-icon icon="lucide:log-out"></iconify-icon>
Log out
</button>
</div>
</div>
</div>
</div>
</div>
</nav>
<div class="flex pt-20 max-w-7xl mx-auto w-full px-6 py-8 gap-8">
<!-- Sidebar -->
<aside class="w-56 flex-shrink-0 hidden md:block">
<nav class="space-y-1">
<RouterLink
v-for="item in navItems"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 px-4 py-2.5 rounded-xl text-sm font-bold transition-colors"
:class="isActive(item.to)
? 'bg-[#bb5b3e] text-white'
: 'text-[#89726c] hover:bg-white hover:text-[#4a3f3b]'"
>
<iconify-icon :icon="item.icon"></iconify-icon>
{{ item.label }}
</RouterLink>
</nav>
</aside>
<!-- Content -->
<main class="flex-1 min-w-0">
<RouterView />
</main>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js'
const { user, logout } = useAuth()
const $route = useRoute()
const router = useRouter()
const userInitials = computed(() => {
if (!user.value?.name) {
return '?'
}
return user.value.name
.split(' ')
.slice(0, 2)
.map((w) => w[0])
.join('')
.toUpperCase()
})
async function handleLogout() {
await logout()
router.push('/')
}
function isActive(to) {
if (to === '/dashboard') {
return $route.path === '/dashboard'
}
return $route.path.startsWith(to)
}
const navItems = [
{ to: '/dashboard', label: 'Overview', icon: 'lucide:layout-dashboard' },
{ to: '/dashboard/saved-stations', label: 'Saved Stations', icon: 'lucide:bookmark' },
{ to: '/dashboard/preferences', label: 'Preferences', icon: 'lucide:settings' },
{ to: '/dashboard/settings', label: 'Account', icon: 'lucide:user' },
]
</script>
- Step 2: Build and verify
npm run build 2>&1 | tail -20
Expected: No errors.
- Step 3: Commit
git add resources/js/views/dashboard/DashboardLayout.vue
git commit -m "feat: add user avatar dropdown with settings and logout to dashboard nav"
Task 4: Router routes + SettingsLayout.vue
Files:
-
Modify:
resources/js/router/index.js -
Create:
resources/js/views/dashboard/settings/SettingsLayout.vue -
Step 1: Replace
resources/js/router/index.js:
import { createRouter, createWebHistory } from 'vue-router'
import Home from '../views/Home.vue'
import DashboardLayout from '../views/dashboard/DashboardLayout.vue'
import Overview from '../views/dashboard/Overview.vue'
import SavedStations from '../views/dashboard/SavedStations.vue'
import Preferences from '../views/dashboard/Preferences.vue'
import SettingsLayout from '../views/dashboard/settings/SettingsLayout.vue'
import Profile from '../views/dashboard/settings/Profile.vue'
import Security from '../views/dashboard/settings/Security.vue'
import Appearance from '../views/dashboard/settings/Appearance.vue'
const routes = [
{ path: '/', component: Home, name: 'home' },
{
path: '/dashboard',
component: DashboardLayout,
children: [
{ path: '', component: Overview, name: 'dashboard' },
{ path: 'saved-stations', component: SavedStations, name: 'dashboard.saved-stations' },
{ path: 'preferences', component: Preferences, name: 'dashboard.preferences' },
{
path: 'settings',
component: SettingsLayout,
redirect: '/dashboard/settings/profile',
children: [
{ path: 'profile', component: Profile, name: 'dashboard.settings.profile' },
{ path: 'security', component: Security, name: 'dashboard.settings.security' },
{ path: 'appearance', component: Appearance, name: 'dashboard.settings.appearance' },
],
},
],
},
]
export default createRouter({
history: createWebHistory(),
routes,
})
- Step 2: Create
resources/js/views/dashboard/settings/SettingsLayout.vue:
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-black text-[#4a3f3b]">Settings</h1>
<p class="text-[#89726c] mt-1">Manage your account and preferences.</p>
</div>
<div class="flex gap-6">
<!-- Settings sub-nav -->
<aside class="w-44 flex-shrink-0">
<nav class="space-y-1">
<RouterLink
v-for="item in settingsNav"
:key="item.to"
:to="item.to"
class="flex items-center gap-3 px-3 py-2 rounded-xl text-sm font-bold transition-colors"
:class="$route.path === item.to
? 'bg-[#bb5b3e] text-white'
: 'text-[#89726c] hover:bg-white hover:text-[#4a3f3b]'"
>
<iconify-icon :icon="item.icon"></iconify-icon>
{{ item.label }}
</RouterLink>
</nav>
</aside>
<!-- Settings content -->
<div class="flex-1 min-w-0">
<RouterView />
</div>
</div>
</div>
</template>
<script setup>
import { RouterLink, RouterView, useRoute } from 'vue-router'
const $route = useRoute()
const settingsNav = [
{ to: '/dashboard/settings/profile', label: 'Profile', icon: 'lucide:user' },
{ to: '/dashboard/settings/security', label: 'Security', icon: 'lucide:shield' },
{ to: '/dashboard/settings/appearance', label: 'Appearance', icon: 'lucide:sun-moon' },
]
</script>
- Step 3: Build and verify
npm run build 2>&1 | tail -20
Expected: No errors (even though Profile/Security/Appearance don't exist yet, the imports are declared).
Note: This step will fail until Task 5–7 create the three view files. Create placeholder stubs if needed:
mkdir -p resources/js/views/dashboard/settings
echo '<template><div></div></template>' > resources/js/views/dashboard/settings/Profile.vue
echo '<template><div></div></template>' > resources/js/views/dashboard/settings/Security.vue
echo '<template><div></div></template>' > resources/js/views/dashboard/settings/Appearance.vue
Then build. Replace stubs in Tasks 5–7.
- Step 4: Commit
git add resources/js/router/index.js resources/js/views/dashboard/settings/
git commit -m "feat: add settings routes and SettingsLayout sub-nav"
Task 5: Profile.vue
Files:
-
Modify (replace stub):
resources/js/views/dashboard/settings/Profile.vue -
Step 1: Replace
resources/js/views/dashboard/settings/Profile.vue:
<template>
<div class="space-y-6 max-w-lg">
<!-- Profile form -->
<form @submit.prevent="saveProfile" class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-5">
<h2 class="text-lg font-black text-[#4a3f3b]">Profile information</h2>
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Full name</label>
<input
v-model="profileForm.name"
type="text"
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
:class="profileErrors.name ? 'border-red-400' : 'border-[#e5ded7]'"
/>
<p v-if="profileErrors.name" class="text-xs text-red-600">{{ profileErrors.name[0] }}</p>
</div>
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Email address</label>
<input
v-model="profileForm.email"
type="email"
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
:class="profileErrors.email ? 'border-red-400' : 'border-[#e5ded7]'"
/>
<p v-if="profileErrors.email" class="text-xs text-red-600">{{ profileErrors.email[0] }}</p>
</div>
<p v-if="profileNetworkError" class="text-sm text-red-600">{{ profileNetworkError }}</p>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="profileSaving"
class="px-8 py-3 bg-[#bb5b3e] text-white rounded-xl font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
>
{{ profileSaving ? 'Saving…' : 'Save changes' }}
</button>
<p v-if="profileSaved" class="text-sm font-bold text-green-600">Saved!</p>
</div>
</form>
<!-- Delete account -->
<div class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-4">
<h2 class="text-lg font-black text-[#4a3f3b]">Delete account</h2>
<p class="text-sm text-[#89726c]">Permanently delete your account and all associated data. This cannot be undone.</p>
<button
@click="deleteModalOpen = true"
class="px-6 py-2.5 bg-red-600 text-white rounded-xl text-sm font-bold hover:bg-red-700 transition-colors"
>
Delete my account
</button>
</div>
<!-- Delete account modal -->
<div
v-if="deleteModalOpen"
class="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
@click.self="closeDeleteModal"
>
<div class="bg-white rounded-2xl p-6 w-full max-w-md mx-4 space-y-5">
<h3 class="text-lg font-black text-[#4a3f3b]">Are you sure?</h3>
<p class="text-sm text-[#89726c]">Enter your password to confirm account deletion.</p>
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Password</label>
<input
v-model="deletePassword"
type="password"
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-red-400"
:class="deleteError ? 'border-red-400' : 'border-[#e5ded7]'"
/>
<p v-if="deleteError" class="text-xs text-red-600">{{ deleteError }}</p>
</div>
<div class="flex gap-3">
<button
@click="closeDeleteModal"
class="flex-1 py-3 border border-[#e5ded7] rounded-xl text-sm font-bold text-[#89726c] hover:bg-[#faf6f3] transition-colors"
>
Cancel
</button>
<button
@click="confirmDelete"
:disabled="deleting"
class="flex-1 py-3 bg-red-600 text-white rounded-xl text-sm font-bold hover:bg-red-700 transition-colors disabled:opacity-50"
>
{{ deleting ? 'Deleting…' : 'Delete account' }}
</button>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { useAuth } from '../../../composables/useAuth.js'
const { user, updateProfile, deleteAccount } = useAuth()
const router = useRouter()
const profileForm = ref({ name: '', email: '' })
const profileErrors = ref({})
const profileNetworkError = ref('')
const profileSaving = ref(false)
const profileSaved = ref(false)
onMounted(() => {
profileForm.value.name = user.value?.name ?? ''
profileForm.value.email = user.value?.email ?? ''
})
async function saveProfile() {
profileSaving.value = true
profileSaved.value = false
profileErrors.value = {}
profileNetworkError.value = ''
try {
await updateProfile(profileForm.value)
profileSaved.value = true
setTimeout(() => { profileSaved.value = false }, 3000)
} catch (err) {
if (err.response?.status === 422) {
profileErrors.value = err.response.data.errors ?? {}
} else {
profileNetworkError.value = 'Something went wrong. Please try again.'
}
} finally {
profileSaving.value = false
}
}
const deleteModalOpen = ref(false)
const deletePassword = ref('')
const deleteError = ref('')
const deleting = ref(false)
function closeDeleteModal() {
deleteModalOpen.value = false
deletePassword.value = ''
deleteError.value = ''
}
async function confirmDelete() {
deleting.value = true
deleteError.value = ''
try {
await deleteAccount(deletePassword.value)
router.push('/')
} catch (err) {
if (err.response?.status === 422) {
deleteError.value = err.response.data.errors?.password?.[0] ?? 'Incorrect password.'
} else {
deleteError.value = 'Something went wrong. Please try again.'
}
deletePassword.value = ''
} finally {
deleting.value = false
}
}
</script>
- Step 2: Build and verify
npm run build 2>&1 | tail -20
Expected: No errors.
- Step 3: Commit
git add resources/js/views/dashboard/settings/Profile.vue
git commit -m "feat: add Profile settings view with name/email form and delete account modal"
Task 6: Security.vue
Files:
-
Modify (replace stub):
resources/js/views/dashboard/settings/Security.vue -
Step 1: Replace
resources/js/views/dashboard/settings/Security.vue:
<template>
<div class="space-y-6 max-w-lg">
<!-- Password change -->
<form @submit.prevent="savePassword" class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-5">
<h2 class="text-lg font-black text-[#4a3f3b]">Change password</h2>
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Current password</label>
<input
v-model="passwordForm.current_password"
type="password"
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
:class="passwordErrors.current_password ? 'border-red-400' : 'border-[#e5ded7]'"
/>
<p v-if="passwordErrors.current_password" class="text-xs text-red-600">{{ passwordErrors.current_password[0] }}</p>
</div>
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">New password</label>
<input
v-model="passwordForm.password"
type="password"
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
:class="passwordErrors.password ? 'border-red-400' : 'border-[#e5ded7]'"
/>
<p v-if="passwordErrors.password" class="text-xs text-red-600">{{ passwordErrors.password[0] }}</p>
</div>
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Confirm new password</label>
<input
v-model="passwordForm.password_confirmation"
type="password"
class="w-full h-12 px-4 bg-[#faf6f3] border rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
:class="passwordErrors.password_confirmation ? 'border-red-400' : 'border-[#e5ded7]'"
/>
<p v-if="passwordErrors.password_confirmation" class="text-xs text-red-600">{{ passwordErrors.password_confirmation[0] }}</p>
</div>
<p v-if="passwordNetworkError" class="text-sm text-red-600">{{ passwordNetworkError }}</p>
<div class="flex items-center gap-4">
<button
type="submit"
:disabled="passwordSaving"
class="px-8 py-3 bg-[#bb5b3e] text-white rounded-xl font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
>
{{ passwordSaving ? 'Updating…' : 'Update password' }}
</button>
<p v-if="passwordSaved" class="text-sm font-bold text-green-600">Password updated!</p>
</div>
</form>
<!-- Two-factor authentication -->
<div class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-black text-[#4a3f3b]">Two-factor authentication</h2>
<p class="text-sm text-[#89726c] mt-0.5">
{{ twoFactorEnabled ? 'Enabled — your account is extra secure.' : 'Add extra security to your account.' }}
</p>
</div>
<span
class="px-3 py-1 rounded-full text-xs font-black"
:class="twoFactorEnabled ? 'bg-green-100 text-green-700' : 'bg-[#faf6f3] text-[#89726c]'"
>
{{ twoFactorEnabled ? 'ON' : 'OFF' }}
</span>
</div>
<!-- 2FA enabling flow -->
<div v-if="enablingTwoFactor" class="space-y-4 border-t border-[#e5ded7] pt-4">
<p class="text-sm font-bold text-[#4a3f3b]">Scan this QR code in your authenticator app:</p>
<div v-if="setupData" class="flex flex-col items-start gap-4">
<!-- eslint-disable-next-line vue/no-v-html -->
<div v-html="setupData.svg" class="[&_svg]:w-40 [&_svg]:h-40"></div>
<div class="space-y-1">
<p class="text-xs font-bold text-[#89726c] uppercase tracking-widest">Or enter setup key manually</p>
<code class="text-xs bg-[#faf6f3] px-3 py-2 rounded-lg font-mono text-[#4a3f3b] break-all block">{{ setupData.secretKey }}</code>
</div>
</div>
<div v-if="!twoFactorEnabled" class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Enter the 6-digit code to confirm</label>
<div class="flex gap-3">
<input
v-model="confirmCode"
type="text"
maxlength="6"
inputmode="numeric"
placeholder="000000"
class="w-36 h-12 px-4 bg-[#faf6f3] border rounded-xl font-mono text-center text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
:class="confirmError ? 'border-red-400' : 'border-[#e5ded7]'"
/>
<button
@click="confirmTwoFactor"
:disabled="confirmCode.length !== 6 || confirming"
class="px-6 py-3 bg-[#bb5b3e] text-white rounded-xl text-sm font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
>
{{ confirming ? 'Verifying…' : 'Confirm' }}
</button>
</div>
<p v-if="confirmError" class="text-xs text-red-600">{{ confirmError }}</p>
</div>
<p v-if="twoFactorEnabled" class="text-sm font-bold text-green-600">
Two-factor authentication is now active.
</p>
<button
v-if="!twoFactorEnabled"
@click="cancelEnable"
class="text-sm text-[#89726c] hover:text-[#4a3f3b]"
>
Cancel
</button>
</div>
<!-- Recovery codes (when 2FA enabled and not mid-setup) -->
<div v-if="twoFactorEnabled && !enablingTwoFactor" class="border-t border-[#e5ded7] pt-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-[#4a3f3b]">Recovery codes</p>
<button
@click="toggleRecoveryCodes"
class="text-xs font-bold text-[#bb5b3e] hover:underline"
>
{{ showRecoveryCodes ? 'Hide' : 'Show' }}
</button>
</div>
<div v-if="showRecoveryCodes" class="space-y-2">
<div class="grid grid-cols-2 gap-1 bg-[#faf6f3] rounded-xl p-4">
<code
v-for="code in recoveryCodes"
:key="code"
class="text-xs font-mono text-[#4a3f3b]"
>{{ code }}</code>
</div>
<button
@click="regenRecoveryCodes"
:disabled="regenLoading"
class="text-xs font-bold text-[#89726c] hover:text-[#4a3f3b]"
>
{{ regenLoading ? 'Regenerating…' : 'Regenerate codes' }}
</button>
</div>
</div>
<!-- Action buttons -->
<div class="flex gap-3 border-t border-[#e5ded7] pt-4">
<button
v-if="!twoFactorEnabled && !enablingTwoFactor"
@click="enableTwoFactor"
:disabled="tfaActionLoading"
class="px-6 py-2.5 bg-[#bb5b3e] text-white rounded-xl text-sm font-bold hover:bg-[#a34a31] transition-colors disabled:opacity-50"
>
{{ tfaActionLoading ? 'Enabling…' : 'Enable 2FA' }}
</button>
<button
v-if="twoFactorEnabled && !enablingTwoFactor"
@click="disableTwoFactor"
:disabled="tfaActionLoading"
class="px-6 py-2.5 bg-white border border-[#e5ded7] rounded-xl text-sm font-bold text-[#89726c] hover:border-red-300 hover:text-red-600 transition-colors disabled:opacity-50"
>
{{ tfaActionLoading ? 'Disabling…' : 'Disable 2FA' }}
</button>
</div>
<p v-if="tfaError" class="text-sm text-red-600">{{ tfaError }}</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import axios from 'axios'
import { useAuth } from '../../../composables/useAuth.js'
const { user, updatePassword } = useAuth()
// Separate axios instance for Fortify web routes (base URL is / not /api)
const web = axios.create({
baseURL: '/',
withCredentials: true,
withXSRFToken: true,
headers: {
Accept: 'application/json',
'X-Requested-With': 'XMLHttpRequest',
},
})
// Password form
const passwordForm = ref({ current_password: '', password: '', password_confirmation: '' })
const passwordErrors = ref({})
const passwordNetworkError = ref('')
const passwordSaving = ref(false)
const passwordSaved = ref(false)
async function savePassword() {
passwordSaving.value = true
passwordSaved.value = false
passwordErrors.value = {}
passwordNetworkError.value = ''
try {
await updatePassword(passwordForm.value)
passwordForm.value = { current_password: '', password: '', password_confirmation: '' }
passwordSaved.value = true
setTimeout(() => { passwordSaved.value = false }, 3000)
} catch (err) {
if (err.response?.status === 422) {
passwordErrors.value = err.response.data.errors ?? {}
} else {
passwordNetworkError.value = 'Something went wrong. Please try again.'
}
} finally {
passwordSaving.value = false
}
}
// 2FA
const twoFactorEnabled = ref(false)
const enablingTwoFactor = ref(false)
const setupData = ref(null)
const confirmCode = ref('')
const confirmError = ref('')
const confirming = ref(false)
const showRecoveryCodes = ref(false)
const recoveryCodes = ref([])
const regenLoading = ref(false)
const tfaActionLoading = ref(false)
const tfaError = ref('')
onMounted(async () => {
twoFactorEnabled.value = !!user.value?.two_factor_confirmed_at
// Ensure CSRF cookie is fresh for Fortify web routes
await axios.get('/sanctum/csrf-cookie', { withCredentials: true })
})
async function enableTwoFactor() {
tfaActionLoading.value = true
tfaError.value = ''
try {
await web.post('/user/two-factor-authentication')
const [qrRes, keyRes] = await Promise.all([
web.get('/user/two-factor-qr-code'),
web.get('/user/two-factor-secret-key'),
])
setupData.value = { svg: qrRes.data.svg, secretKey: keyRes.data.secretKey }
enablingTwoFactor.value = true
} catch {
tfaError.value = 'Failed to enable 2FA. Please try again.'
} finally {
tfaActionLoading.value = false
}
}
async function confirmTwoFactor() {
confirming.value = true
confirmError.value = ''
try {
await web.post('/user/confirmed-two-factor-authentication', { code: confirmCode.value })
twoFactorEnabled.value = true
confirmCode.value = ''
await loadRecoveryCodes()
} catch (err) {
confirmError.value = err.response?.data?.errors?.code?.[0] ?? 'Invalid code. Please try again.'
confirmCode.value = ''
} finally {
confirming.value = false
}
}
async function disableTwoFactor() {
tfaActionLoading.value = true
tfaError.value = ''
try {
await web.delete('/user/two-factor-authentication')
twoFactorEnabled.value = false
showRecoveryCodes.value = false
recoveryCodes.value = []
} catch {
tfaError.value = 'Failed to disable 2FA. Please try again.'
} finally {
tfaActionLoading.value = false
}
}
function cancelEnable() {
enablingTwoFactor.value = false
setupData.value = null
confirmCode.value = ''
confirmError.value = ''
}
async function loadRecoveryCodes() {
try {
const response = await web.get('/user/two-factor-recovery-codes')
recoveryCodes.value = response.data
} catch {
// Non-fatal
}
}
async function toggleRecoveryCodes() {
if (!showRecoveryCodes.value && recoveryCodes.value.length === 0) {
await loadRecoveryCodes()
}
showRecoveryCodes.value = !showRecoveryCodes.value
}
async function regenRecoveryCodes() {
regenLoading.value = true
try {
await web.post('/user/two-factor-recovery-codes')
await loadRecoveryCodes()
} catch {
// Silently fail
} finally {
regenLoading.value = false
}
}
</script>
- Step 2: Build and verify
npm run build 2>&1 | tail -20
Expected: No errors.
- Step 3: Commit
git add resources/js/views/dashboard/settings/Security.vue
git commit -m "feat: add Security settings view with password update and 2FA management"
Task 7: Appearance.vue
Files:
-
Modify (replace stub):
resources/js/views/dashboard/settings/Appearance.vue -
Step 1: Replace
resources/js/views/dashboard/settings/Appearance.vue:
<template>
<div class="space-y-6 max-w-lg">
<div class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-5">
<h2 class="text-lg font-black text-[#4a3f3b]">Appearance</h2>
<p class="text-sm text-[#89726c]">Choose how FuelAlert looks to you.</p>
<div class="grid grid-cols-3 gap-3">
<button
v-for="option in themeOptions"
:key="option.value"
@click="setTheme(option.value)"
class="flex flex-col items-center gap-3 p-4 rounded-2xl border-2 transition-all"
:class="selectedTheme === option.value
? 'border-[#bb5b3e] bg-[#faf6f3]'
: 'border-[#e5ded7] hover:border-[#89726c]'"
>
<iconify-icon :icon="option.icon" class="text-2xl text-[#bb5b3e]"></iconify-icon>
<span class="text-sm font-bold text-[#4a3f3b]">{{ option.label }}</span>
</button>
</div>
<p class="text-xs text-[#89726c]">
Current: <span class="font-bold capitalize">{{ selectedTheme }}</span>
</p>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
const selectedTheme = ref('system')
const themeOptions = [
{ value: 'light', label: 'Light', icon: 'lucide:sun' },
{ value: 'dark', label: 'Dark', icon: 'lucide:moon' },
{ value: 'system', label: 'System', icon: 'lucide:monitor' },
]
onMounted(() => {
selectedTheme.value = localStorage.getItem('appearance') ?? 'system'
})
function setTheme(value) {
selectedTheme.value = value
localStorage.setItem('appearance', value)
applyTheme(value)
}
function applyTheme(value) {
const html = document.documentElement
html.classList.remove('light', 'dark')
if (value === 'system') {
if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
html.classList.add('dark')
}
} else {
html.classList.add(value)
}
}
</script>
- Step 2: Build and verify
npm run build 2>&1 | tail -20
Expected: No errors.
- Step 3: Commit
git add resources/js/views/dashboard/settings/Appearance.vue
git commit -m "feat: add Appearance settings view with light/dark/system theme toggle"
Task 8: Cleanup — remove Livewire settings
Files:
-
Modify:
routes/web.php -
Delete: See list below
-
Step 1: Replace
routes/web.php(removes therequire settings.phpline):
<?php
use Illuminate\Support\Facades\Route;
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');
// SPA catch-all — must be last
Route::get('/{any?}', fn () => view('app'))->where('any', '.*')->name('home');
- Step 2: Delete all Livewire settings files:
rm routes/settings.php
rm app/Livewire/Settings/Profile.php
rm app/Livewire/Settings/Security.php
rm app/Livewire/Settings/Appearance.php
rm app/Livewire/Settings/DeleteUserForm.php
rm app/Livewire/Settings/TwoFactor/RecoveryCodes.php
rmdir app/Livewire/Settings/TwoFactor
rmdir app/Livewire/Settings
rm resources/views/livewire/settings/profile.blade.php
rm resources/views/livewire/settings/security.blade.php
rm resources/views/livewire/settings/appearance.blade.php
rm resources/views/livewire/settings/delete-user-form.blade.php
rm resources/views/livewire/settings/two-factor/recovery-codes.blade.php
rmdir resources/views/livewire/settings/two-factor
rmdir resources/views/livewire/settings
rm resources/views/components/settings/layout.blade.php
rmdir resources/views/components/settings
rm tests/Feature/Settings/SecurityTest.php
rm tests/Feature/Settings/ProfileUpdateTest.php
rmdir tests/Feature/Settings
- Step 3: Run Pint
vendor/bin/pint --dirty --format agent
- Step 4: Run the full test suite
php artisan test --compact --timeout=10
Expected: All tests pass. The deleted Settings tests no longer run. No references to route('profile.edit') or route('security.edit') remain.
- Step 5: Commit
git add -A
git commit -m "chore: remove Livewire settings pages — migrated to Vue SPA"