diff --git a/docs/superpowers/plans/2026-04-11-settings-vue-migration.md b/docs/superpowers/plans/2026-04-11-settings-vue-migration.md new file mode 100644 index 0000000..205c832 --- /dev/null +++ b/docs/superpowers/plans/2026-04-11-settings-vue-migration.md @@ -0,0 +1,1366 @@ +# 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" +```