# 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 `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 ``` - [ ] **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 ``` - [ ] **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 5–7 create the three view files. Create placeholder stubs if needed: ```bash mkdir -p resources/js/views/dashboard/settings echo '' > resources/js/views/dashboard/settings/Profile.vue echo '' > resources/js/views/dashboard/settings/Security.vue echo '' > resources/js/views/dashboard/settings/Appearance.vue ``` Then build. Replace stubs in Tasks 5–7. - [ ] **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 ``` - [ ] **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 ``` - [ ] **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 ``` - [ ] **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 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" ```