Files
fuel-price/docs/superpowers/plans/2026-04-11-settings-vue-migration.md

1367 lines
48 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 modal
- `resources/js/views/dashboard/settings/Security.vue` — Password form + 2FA management
- `resources/js/views/dashboard/settings/Appearance.vue` — Theme toggle (localStorage)
**Modified files:**
- `tests/Feature/Api/UserControllerTest.php` — New tests for the three new API actions
- `app/Http/Controllers/Api/UserController.php` — Add `updateProfile`, `updatePassword`, `deleteAccount`
- `routes/api.php` — Register 3 new routes
- `resources/js/composables/useAuth.js` — Add `logout`, `updateProfile`, `updatePassword`, `deleteAccount`
- `resources/js/views/dashboard/DashboardLayout.vue` — Replace email text with user avatar dropdown
- `resources/js/router/index.js` — Add `/dashboard/settings` nested routes
**Deleted in Task 8:**
- `routes/settings.php` + `require` line from `routes/web.php`
- `app/Livewire/Settings/` (5 files + subdirectory)
- `resources/views/livewire/settings/` (5 files + subdirectory)
- `resources/views/components/settings/layout.blade.php`
- `tests/Feature/Settings/SecurityTest.php` and `ProfileUpdateTest.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 `Hash` import** to the top of `tests/Feature/Api/UserControllerTest.php` (after `<?php`):
```php
use Illuminate\Support\Facades\Hash;
```
- [ ] **Step 2: Append failing tests** to `tests/Feature/Api/UserControllerTest.php`:
```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**
```bash
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:
```php
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:
```php
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.php` inside the `auth:sanctum` group, after the saved-stations routes:
```php
Route::put('/user/profile', [UserController::class, 'updateProfile']);
Route::put('/user/password', [UserController::class, 'updatePassword']);
Route::delete('/user', [UserController::class, 'deleteAccount']);
```
- [ ] **Step 6: Run Pint**
```bash
vendor/bin/pint --dirty --format agent
```
- [ ] **Step 7: Run tests to confirm they pass**
```bash
php artisan test --compact tests/Feature/Api/UserControllerTest.php --timeout=10
```
Expected: All PASS.
- [ ] **Step 8: Commit**
```bash
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`:
```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**
```bash
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`:
```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**
```bash
npm run build 2>&1 | tail -20
```
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
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`**:
```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`**:
```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**
```bash
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 57 create the three view files. Create placeholder stubs if needed:
```bash
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 57.
- [ ] **Step 4: Commit**
```bash
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`**:
```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**
```bash
npm run build 2>&1 | tail -20
```
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
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`**:
```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**
```bash
npm run build 2>&1 | tail -20
```
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
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`**:
```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**
```bash
npm run build 2>&1 | tail -20
```
Expected: No errors.
- [ ] **Step 3: Commit**
```bash
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 the `require settings.php` line):
```php
<?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**:
```bash
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**
```bash
vendor/bin/pint --dirty --format agent
```
- [ ] **Step 4: Run the full test suite**
```bash
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**
```bash
git add -A
git commit -m "chore: remove Livewire settings pages — migrated to Vue SPA"
```