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

48 KiB
Raw Blame History

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):

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.php inside the auth:sanctum group, 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 57 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 57.

  • 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 the require settings.php line):

<?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"