1367 lines
48 KiB
Markdown
1367 lines
48 KiB
Markdown
# 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 5–7 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 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
|
||
<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"
|
||
```
|