Compare commits

...

15 Commits

Author SHA1 Message Date
Ovidiu U
069a85cf11 refactor: migrate from hardcoded hex colors to Tailwind CSS color tokens
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (8.3) (push) Has been cancelled
tests / ci (8.4) (push) Has been cancelled
tests / ci (8.5) (push) Has been cancelled
Replace all hardcoded hex color values with semantic Tailwind design tokens:
- `#bb5b3e` → `accent`
- `#a34a31` → `accent-content` / `primary-dark`
- `#4a3f3b`, `#89726c` → `zinc-800`, `zinc-500`
- `#e5ded7`, `#faf6f3` → `zinc-300`, `zinc-50`
- `#8
2026-04-11 16:26:34 +01:00
Ovidiu U
02b004f381 fix: handle TransientToken in logout for session-based auth
When the SPA authenticates via cookies (not Bearer token), Sanctum returns
a TransientToken from currentAccessToken() which has no delete() method.
Detect it and invalidate the session instead.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:29:03 +01:00
Ovidiu U
977ae8a5a1 chore: remove orphaned settings-heading partial
settings-heading.blade.php was a Livewire settings layout partial with no
remaining references after the settings Vue migration.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:27:47 +01:00
Ovidiu U
25770445bc fix: correct $route naming and aria-expanded type in DashboardLayout
- Rename \$route → route (no \$ prefix in script setup, that's Options API)
- Use string 'true'/'false' for aria-expanded (ARIA spec requires string)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:26:22 +01:00
Ovidiu U
3895356b0d fix: replace Alpine dropdown with Vue reactive state in DashboardLayout
Alpine.js is not loaded in the Vue SPA bundle, causing the avatar dropdown
to never open and making Settings and Log out inaccessible. Replaced x-data/
x-show/x-transition/@click.away with Vue refs, onMounted/onUnmounted click-
outside listener, and Vue's built-in <Transition> component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:21:27 +01:00
Ovidiu U
ea7a5b4f10 chore: remove Livewire settings pages — migrated to Vue SPA
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:14:34 +01:00
Ovidiu U
83809cd4f3 feat: add Appearance settings view with light/dark/system theme toggle 2026-04-11 13:13:09 +01:00
Ovidiu U
f714169183 feat: add Security settings view with password update and 2FA management
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:11:58 +01:00
Ovidiu U
00e99044f6 feat: add Profile settings view with name/email form and delete account modal 2026-04-11 13:09:40 +01:00
Ovidiu U
5bf8868124 feat: add settings routes and SettingsLayout sub-nav 2026-04-11 13:08:11 +01:00
Ovidiu U
bd68a179d8 feat: add user avatar dropdown with settings and logout to dashboard nav 2026-04-11 13:07:00 +01:00
Ovidiu U
7976b9facc feat: add logout, updateProfile, updatePassword, deleteAccount to useAuth 2026-04-11 13:05:04 +01:00
Ovidiu U
e90078d39e feat: add updateProfile, updatePassword, deleteAccount API endpoints
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 13:02:23 +01:00
Ovidiu U
94d695d637 docs: add settings Vue migration implementation plan 2026-04-11 12:54:57 +01:00
Ovidiu U
1d39c69fe4 docs: add settings Vue migration design spec 2026-04-11 12:41:27 +01:00
41 changed files with 2945 additions and 1418 deletions

View File

@@ -1,156 +0,0 @@
---
name: livewire-development
description: "Use for any task or question involving Livewire. Activate if user mentions Livewire, wire: directives, or Livewire-specific concepts like wire:model, wire:click, wire:sort, or islands, invoke this skill. Covers building new components, debugging reactivity issues, real-time form validation, drag-and-drop, loading states, migrating from Livewire 3 to 4, converting component formats (SFC/MFC/class-based), and performance optimization. Do not use for non-Livewire reactive UI (React, Vue, Alpine-only, Inertia.js) or standard Laravel forms without Livewire."
license: MIT
metadata:
author: laravel
---
# Livewire Development
## Documentation
Use `search-docs` for detailed Livewire 4 patterns and documentation.
## Basic Usage
### Creating Components
```bash
# Single-file component (default in v4)
php artisan make:livewire create-post
# Multi-file component
php artisan make:livewire create-post --mfc
# Class-based component (v3 style)
php artisan make:livewire create-post --class
# With namespace
php artisan make:livewire Posts/CreatePost
```
### Converting Between Formats
Use `php artisan livewire:convert create-post` to convert between single-file, multi-file, and class-based formats.
### Choosing a Component Format
Before creating a component, check `config/livewire.php` for directory overrides, which change where files are stored. Then, look at existing files in those directories (defaulting to `app/Livewire/` and `resources/views/livewire/`) to match the established convention.
### Component Format Reference
| Format | Flag | Class Path | View Path |
|--------|------|------------|-----------|
| Single-file (SFC) | default | — | `resources/views/livewire/create-post.blade.php` (PHP + Blade in one file) |
| Multi-file (MFC) | `--mfc` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
| Class-based | `--class` | `app/Livewire/CreatePost.php` | `resources/views/livewire/create-post.blade.php` |
| View-based | ⚡ prefix | — | `resources/views/livewire/create-post.blade.php` (Blade-only with functional state) |
Namespaced components map to subdirectories: `make:livewire Posts/CreatePost` creates files at `app/Livewire/Posts/CreatePost.php` and `resources/views/livewire/posts/create-post.blade.php`.
### Single-File Component Example
<!-- Single-File Component Example -->
```php
<?php
use Livewire\Component;
new class extends Component {
public int $count = 0;
public function increment(): void
{
$this->count++;
}
}
?>
<div>
<button wire:click="increment">Count: @{{ $count }}</button>
</div>
```
## Livewire 4 Specifics
### Key Changes From Livewire 3
These things changed in Livewire 4, but may not have been updated in this application. Verify this application's setup to ensure you follow existing conventions.
- Use `Route::livewire()` for full-page components (e.g., `Route::livewire('/posts/create', CreatePost::class)`); config keys renamed: `layout``component_layout`, `lazy_placeholder``component_placeholder`.
- `wire:model` now ignores child events by default (use `wire:model.deep` for old behavior); `wire:scroll` renamed to `wire:navigate:scroll`.
- Component tags must be properly closed; `wire:transition` now uses View Transitions API (modifiers removed).
- JavaScript: `$wire.$js('name', fn)``$wire.$js.name = fn`; `commit`/`request` hooks → `interceptMessage()`/`interceptRequest()`.
### New Features
- Component formats: single-file (SFC), multi-file (MFC), view-based components.
- Islands (`@island`) for isolated updates; async actions (`wire:click.async`, `#[Async]`) for parallel execution.
- Deferred/bundled loading: `defer`, `lazy.bundle` for optimized component loading.
| Feature | Usage | Purpose |
|---------|-------|---------|
| Islands | `@island(name: 'stats')` | Isolated update regions |
| Async | `wire:click.async` or `#[Async]` | Non-blocking actions |
| Deferred | `defer` attribute | Load after page render |
| Bundled | `lazy.bundle` | Load multiple together |
### New Directives
- `wire:sort`, `wire:intersect`, `wire:ref`, `.renderless`, `.preserve-scroll` are available for use.
- `data-loading` attribute automatically added to elements triggering network requests.
| Directive | Purpose |
|-----------|---------|
| `wire:sort` | Drag-and-drop sorting |
| `wire:intersect` | Viewport intersection detection |
| `wire:ref` | Element references for JS |
| `.renderless` | Component without rendering |
| `.preserve-scroll` | Preserve scroll position |
## Best Practices
- Always use `wire:key` in loops
- Use `wire:loading` for loading states
- Use `wire:model.live` for instant updates (default is debounced)
- Validate and authorize in actions (treat like HTTP requests)
## Configuration
- `smart_wire_keys` defaults to `true`; new configs: `component_locations`, `component_namespaces`, `make_command`, `csp_safe`.
## Alpine & JavaScript
- `wire:transition` uses browser View Transitions API; `$errors` and `$intercept` magic properties available.
- Non-blocking `wire:poll` and parallel `wire:model.live` updates improve performance.
For interceptors and hooks, see [reference/javascript-hooks.md](reference/javascript-hooks.md).
## Testing
<!-- Testing Example -->
```php
Livewire::test(Counter::class)
->assertSet('count', 0)
->call('increment')
->assertSet('count', 1);
```
## Verification
1. Browser console: Check for JS errors
2. Network tab: Verify Livewire requests return 200
3. Ensure `wire:key` on all `@foreach` loops
## Common Pitfalls
- Missing `wire:key` in loops → unexpected re-rendering
- Expecting `wire:model` real-time → use `wire:model.live`
- Unclosed component tags → syntax errors in v4
- Using deprecated config keys or JS hooks
- Including Alpine.js separately (already bundled in Livewire 4)

View File

@@ -1,39 +0,0 @@
# Livewire 4 JavaScript Integration
## Interceptor System (v4)
### Intercept Messages
```js
Livewire.interceptMessage(({ component, message, onFinish, onSuccess, onError }) => {
onFinish(() => { /* After response, before processing */ });
onSuccess(({ payload }) => { /* payload.snapshot, payload.effects */ });
onError(() => { /* Server errors */ });
});
```
### Intercept Requests
```js
Livewire.interceptRequest(({ request, onResponse, onSuccess, onError, onFailure }) => {
onResponse(({ response }) => { /* When received */ });
onSuccess(({ response, responseJson }) => { /* Success */ });
onError(({ response, responseBody, preventDefault }) => { /* 4xx/5xx */ });
onFailure(({ error }) => { /* Network failures */ });
});
```
### Component-Scoped Interceptors
```blade
<script>
this.$intercept('save', ({ component, onSuccess }) => {
onSuccess(() => console.log('Saved!'));
});
</script>
```
## Magic Properties
- `$errors` - Access validation errors from JavaScript
- `$intercept` - Component-scoped interceptors

View File

@@ -8,7 +8,7 @@ use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\Rules\Password;
use Laravel\Sanctum\PersonalAccessToken;
use Laravel\Sanctum\TransientToken;
class AuthController extends Controller
{
@@ -46,9 +46,15 @@ class AuthController extends Controller
public function logout(Request $request): JsonResponse
{
/** @var PersonalAccessToken $token */
$token = $request->user()->currentAccessToken();
// TransientToken means session-based auth (no Bearer token) — invalidate session instead
if ($token instanceof TransientToken) {
$request->session()->invalidate();
$request->session()->regenerateToken();
} else {
$token->delete();
}
return response()->json(['message' => 'Logged out.']);
}

View File

@@ -3,10 +3,14 @@
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\User;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Hash;
use Illuminate\Validation\Rule;
use Illuminate\Validation\Rules\Password;
use Illuminate\Validation\ValidationException;
final class UserController extends Controller
{
@@ -59,4 +63,58 @@ final class UserController extends Controller
return response()->noContent();
}
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): 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();
}
}

View File

@@ -1,12 +0,0 @@
<?php
namespace App\Livewire\Settings;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Appearance settings')]
class Appearance extends Component
{
//
}

View File

@@ -1,29 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\PasswordValidationRules;
use App\Livewire\Actions\Logout;
use Illuminate\Support\Facades\Auth;
use Livewire\Component;
class DeleteUserForm extends Component
{
use PasswordValidationRules;
public string $password = '';
/**
* Delete the currently authenticated user.
*/
public function deleteUser(Logout $logout): void
{
$this->validate([
'password' => $this->currentPasswordRules(),
]);
tap(Auth::user(), $logout(...))->delete();
$this->redirect('/', navigate: true);
}
}

View File

@@ -1,81 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\ProfileValidationRules;
use Flux\Flux;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Support\Facades\Auth;
use Livewire\Attributes\Computed;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Profile settings')]
class Profile extends Component
{
use ProfileValidationRules;
public string $name = '';
public string $email = '';
/**
* Mount the component.
*/
public function mount(): void
{
$this->name = Auth::user()->name;
$this->email = Auth::user()->email;
}
/**
* Update the profile information for the currently authenticated user.
*/
public function updateProfileInformation(): void
{
$user = Auth::user();
$validated = $this->validate($this->profileRules($user->id));
$user->fill($validated);
if ($user->isDirty('email')) {
$user->email_verified_at = null;
}
$user->save();
Flux::toast(variant: 'success', text: __('Profile updated.'));
}
/**
* Send an email verification notification to the current user.
*/
public function resendVerificationNotification(): void
{
$user = Auth::user();
if ($user->hasVerifiedEmail()) {
$this->redirectIntended(default: route('dashboard', absolute: false));
return;
}
$user->sendEmailVerificationNotification();
Flux::toast(text: __('A new verification link has been sent to your email address.'));
}
#[Computed]
public function hasUnverifiedEmail(): bool
{
return Auth::user() instanceof MustVerifyEmail && ! Auth::user()->hasVerifiedEmail();
}
#[Computed]
public function showDeleteUser(): bool
{
return ! Auth::user() instanceof MustVerifyEmail
|| (Auth::user() instanceof MustVerifyEmail && Auth::user()->hasVerifiedEmail());
}
}

View File

@@ -1,225 +0,0 @@
<?php
namespace App\Livewire\Settings;
use App\Concerns\PasswordValidationRules;
use Exception;
use Flux\Flux;
use Illuminate\Support\Facades\Auth;
use Illuminate\Validation\ValidationException;
use Laravel\Fortify\Actions\ConfirmTwoFactorAuthentication;
use Laravel\Fortify\Actions\DisableTwoFactorAuthentication;
use Laravel\Fortify\Actions\EnableTwoFactorAuthentication;
use Laravel\Fortify\Features;
use Laravel\Fortify\Fortify;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Title;
use Livewire\Attributes\Validate;
use Livewire\Component;
#[Title('Security settings')]
class Security extends Component
{
use PasswordValidationRules;
public string $current_password = '';
public string $password = '';
public string $password_confirmation = '';
#[Locked]
public bool $canManageTwoFactor;
#[Locked]
public bool $twoFactorEnabled;
#[Locked]
public bool $requiresConfirmation;
#[Locked]
public string $qrCodeSvg = '';
#[Locked]
public string $manualSetupKey = '';
public bool $showModal = false;
public bool $showVerificationStep = false;
#[Validate('required|string|size:6', onUpdate: false)]
public string $code = '';
/**
* Mount the component.
*/
public function mount(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$this->canManageTwoFactor = Features::canManageTwoFactorAuthentication();
if ($this->canManageTwoFactor) {
if (Fortify::confirmsTwoFactorAuthentication() && is_null(auth()->user()->two_factor_confirmed_at)) {
$disableTwoFactorAuthentication(auth()->user());
}
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
$this->requiresConfirmation = Features::optionEnabled(Features::twoFactorAuthentication(), 'confirm');
}
}
/**
* Update the password for the currently authenticated user.
*/
public function updatePassword(): void
{
try {
$validated = $this->validate([
'current_password' => $this->currentPasswordRules(),
'password' => $this->passwordRules(),
]);
} catch (ValidationException $e) {
$this->reset('current_password', 'password', 'password_confirmation');
throw $e;
}
Auth::user()->update([
'password' => $validated['password'],
]);
$this->reset('current_password', 'password', 'password_confirmation');
Flux::toast(variant: 'success', text: __('Password updated.'));
}
/**
* Enable two-factor authentication for the user.
*/
public function enable(EnableTwoFactorAuthentication $enableTwoFactorAuthentication): void
{
$enableTwoFactorAuthentication(auth()->user());
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
$this->loadSetupData();
$this->showModal = true;
}
/**
* Load the two-factor authentication setup data for the user.
*/
private function loadSetupData(): void
{
$user = auth()->user();
try {
$this->qrCodeSvg = $user?->twoFactorQrCodeSvg();
$this->manualSetupKey = decrypt($user->two_factor_secret);
} catch (Exception) {
$this->addError('setupData', 'Failed to fetch setup data.');
$this->reset('qrCodeSvg', 'manualSetupKey');
}
}
/**
* Show the two-factor verification step if necessary.
*/
public function showVerificationIfNecessary(): void
{
if ($this->requiresConfirmation) {
$this->showVerificationStep = true;
$this->resetErrorBag();
return;
}
$this->closeModal();
}
/**
* Close the two-factor authentication modal.
*/
public function closeModal(): void
{
$this->reset(
'code',
'manualSetupKey',
'qrCodeSvg',
'showModal',
'showVerificationStep',
);
$this->resetErrorBag();
if (! $this->requiresConfirmation) {
$this->twoFactorEnabled = auth()->user()->hasEnabledTwoFactorAuthentication();
}
}
/**
* Confirm two-factor authentication for the user.
*/
public function confirmTwoFactor(ConfirmTwoFactorAuthentication $confirmTwoFactorAuthentication): void
{
$this->validate();
$confirmTwoFactorAuthentication(auth()->user(), $this->code);
$this->closeModal();
$this->twoFactorEnabled = true;
}
/**
* Reset two-factor verification state.
*/
public function resetVerification(): void
{
$this->reset('code', 'showVerificationStep');
$this->resetErrorBag();
}
/**
* Disable two-factor authentication for the user.
*/
public function disable(DisableTwoFactorAuthentication $disableTwoFactorAuthentication): void
{
$disableTwoFactorAuthentication(auth()->user());
$this->twoFactorEnabled = false;
}
/**
* Get the current modal configuration state.
*/
public function getModalConfigProperty(): array
{
if ($this->twoFactorEnabled) {
return [
'title' => __('Two-factor authentication enabled'),
'description' => __('Two-factor authentication is now enabled. Scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Close'),
];
}
if ($this->showVerificationStep) {
return [
'title' => __('Verify authentication code'),
'description' => __('Enter the 6-digit code from your authenticator app.'),
'buttonText' => __('Continue'),
];
}
return [
'title' => __('Enable two-factor authentication'),
'description' => __('To finish enabling two-factor authentication, scan the QR code or enter the setup key in your authenticator app.'),
'buttonText' => __('Continue'),
];
}
}

View File

@@ -1,50 +0,0 @@
<?php
namespace App\Livewire\Settings\TwoFactor;
use Exception;
use Laravel\Fortify\Actions\GenerateNewRecoveryCodes;
use Livewire\Attributes\Locked;
use Livewire\Component;
class RecoveryCodes extends Component
{
#[Locked]
public array $recoveryCodes = [];
/**
* Mount the component.
*/
public function mount(): void
{
$this->loadRecoveryCodes();
}
/**
* Load the recovery codes for the user.
*/
private function loadRecoveryCodes(): void
{
$user = auth()->user();
if ($user->hasEnabledTwoFactorAuthentication() && $user->two_factor_recovery_codes) {
try {
$this->recoveryCodes = json_decode(decrypt($user->two_factor_recovery_codes), true);
} catch (Exception) {
$this->addError('recoveryCodes', 'Failed to load recovery codes');
$this->recoveryCodes = [];
}
}
}
/**
* Generate new recovery codes for the user.
*/
public function regenerateRecoveryCodes(GenerateNewRecoveryCodes $generateNewRecoveryCodes): void
{
$generateNewRecoveryCodes(auth()->user());
$this->loadRecoveryCodes();
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,130 @@
# Settings Vue Migration — Design Spec
**Date:** 2026-04-11
**Status:** Approved
## Goal
Migrate the Livewire settings pages (Profile, Security, Appearance) into the Vue SPA so the app is fully unified under one frontend. Auth pages (`/login`, `/register`, Fortify) remain Livewire and are untouched.
---
## Architecture
### Routing
New Vue Router routes nested under `/dashboard/settings`:
```
/dashboard/settings → redirect to /dashboard/settings/profile
/dashboard/settings/profile
/dashboard/settings/security
/dashboard/settings/appearance
```
A `SettingsLayout.vue` wraps all three views, providing a sub-nav sidebar (Profile / Security / Appearance) that mirrors the existing Livewire settings layout structure and uses the same warm colour palette (`#faf6f3`, `#bb5b3e`, `#89726c`, `#4a3f3b`, `#e5ded7`).
### Top Nav — User Dropdown
`DashboardLayout.vue` top nav: replace the plain `{{ user?.email }}` text with a user avatar button showing the user's initials (e.g. "OU"). Clicking opens a dropdown showing:
- Avatar + full name + email
- Settings link → `/dashboard/settings`
- Log out (calls `POST /api/auth/logout`, clears user, redirects to `/`)
The dropdown is implemented with Alpine.js `x-data` / `x-show` inline, consistent with the project's existing Alpine usage pattern.
---
## Components
### New files
| File | Purpose |
|------|---------|
| `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 |
| `resources/js/views/dashboard/settings/Security.vue` | Password change + 2FA management |
| `resources/js/views/dashboard/settings/Appearance.vue` | Light/dark/system theme toggle |
### Modified files
| File | Change |
|------|--------|
| `resources/js/router/index.js` | Add `/dashboard/settings` routes |
| `resources/js/views/dashboard/DashboardLayout.vue` | Replace email text with user dropdown |
| `resources/js/composables/useAuth.js` | Expose `updateProfile`, `updatePassword`, `deleteAccount` methods |
| `app/Http/Controllers/Api/UserController.php` | Add `updateProfile`, `updatePassword`, `deleteAccount` actions |
| `routes/api.php` | Register 3 new endpoints |
| `routes/settings.php` | Remove Livewire settings routes |
---
## Data Flow
### Profile update
1. Vue calls `PUT /api/user/profile` with `{ name, email }`
2. `UserController@updateProfile` validates, updates `users` table, nulls `email_verified_at` if email changed
3. Returns updated user JSON; `useAuth` updates the `user` ref
### Password update
1. Vue calls `PUT /api/user/password` with `{ current_password, password, password_confirmation }`
2. `UserController@updatePassword` verifies current password via `Hash::check`, updates hash
3. Returns `200` on success; Vue shows inline success message
### Delete account
1. User clicks "Delete account", enters password in a confirmation modal
2. Vue calls `DELETE /api/user` with `{ password }`
3. `UserController@deleteAccount` verifies password, deletes user, revokes Sanctum tokens
4. Vue calls logout, redirects to `/`
### 2FA (uses Fortify web routes directly)
Fortify's web routes accept `application/json` and work with session cookies (axios already sends these via `withCredentials: true`):
| Action | Endpoint |
|--------|----------|
| Enable | `POST /user/two-factor-authentication` |
| Disable | `DELETE /user/two-factor-authentication` |
| Confirm code | `POST /user/confirmed-two-factor-authentication` |
| Get QR code SVG | `GET /user/two-factor-qr-code` |
| Get secret key | `GET /user/two-factor-secret-key` |
| Get recovery codes | `GET /user/two-factor-recovery-codes` |
| Regenerate recovery codes | `POST /user/two-factor-recovery-codes` |
A separate axios instance (base URL `/`, same credentials) is used for these Fortify calls to avoid the `/api` prefix.
### Appearance
Theme preference stored in `localStorage` under key `appearance` with values `light` / `dark` / `system`. Applied via a class on `<html>`. No backend call needed.
---
## Error Handling
- Validation errors from API returned as `422` with `errors` object; displayed inline under each field
- Network errors shown as a generic top-level message within the form card
- 2FA setup errors (QR fetch failure, bad confirmation code) shown inline with retry option
- Delete account modal clears and closes on any error; error shown inside the modal
---
## Cleanup (after Vue implementation verified)
- Remove `routes/settings.php` (all three `Route::livewire` entries)
- Remove `app/Livewire/Settings/` directory (Profile, Security, Appearance, DeleteUserForm, TwoFactor/RecoveryCodes)
- Remove `resources/views/livewire/settings/` directory
- Remove `resources/views/components/settings/layout.blade.php`
- Remove `resources/views/partials/settings-heading.blade.php` if it exists
- Remove `app/Concerns/ProfileValidationRules.php` and `PasswordValidationRules.php` if unused elsewhere
---
## Out of Scope
- `/login`, `/register`, Fortify password reset flows — remain Livewire, untouched
- Subscription management (separate feature)
- WhatsApp/SMS notification preferences (separate feature)

View File

@@ -2,7 +2,7 @@
<div class="space-y-2">
<button
@click="toggleMap"
class="flex items-center gap-2 text-sm font-bold text-[#bb5b3e] hover:text-[#a34a31] transition-colors"
class="flex items-center gap-2 text-sm font-bold text-accent hover:text-accent-content transition-colors"
>
<iconify-icon :icon="isOpen ? 'lucide:chevron-up' : 'lucide:chevron-down'"></iconify-icon>
{{ isOpen ? 'Hide map' : 'Show map' }}
@@ -11,7 +11,7 @@
<div
v-show="isOpen"
ref="mapContainer"
class="w-full h-72 rounded-2xl overflow-hidden border border-[#e5ded7] shadow-sm"
class="w-full h-72 rounded-2xl overflow-hidden border border-zinc-300 shadow-sm"
></div>
</div>
</template>

View File

@@ -5,11 +5,11 @@
v-if="!isPaidTier"
class="absolute inset-0 z-10 rounded-2xl backdrop-blur-sm bg-white/60 flex flex-col items-center justify-center gap-3 text-center px-6"
>
<iconify-icon icon="lucide:lock" class="text-[#bb5b3e] text-3xl"></iconify-icon>
<p class="font-bold text-[#4a3f3b]">Price predictions are available on paid plans</p>
<iconify-icon class="text-accent text-3xl" icon="lucide:lock"></iconify-icon>
<p class="font-bold text-zinc-800">Price predictions are available on paid plans</p>
<a
href="/pricing"
class="px-6 py-2 bg-[#bb5b3e] text-white rounded-full text-sm font-bold hover:bg-[#a34a31] transition-colors"
class="px-6 py-2 bg-accent text-white rounded-full text-sm font-bold hover:bg-accent-content transition-colors"
>
Upgrade from £0.99/mo
</a>
@@ -17,15 +17,15 @@
<!-- Card content (blurred for free users, fully visible for paid) -->
<div
:class="['p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-4', !isPaidTier && 'select-none pointer-events-none']"
:class="['p-6 bg-white rounded-2xl border border-zinc-300 space-y-4', !isPaidTier && 'select-none pointer-events-none']"
>
<p class="text-xs font-bold uppercase tracking-widest text-[#89726c]">Price Prediction</p>
<p class="text-xs font-bold uppercase tracking-widest text-zinc-500">Price Prediction</p>
<!-- Loading state -->
<template v-if="loading">
<div class="animate-pulse space-y-2">
<div class="h-8 bg-[#e5ded7] rounded w-1/2"></div>
<div class="h-4 bg-[#e5ded7] rounded w-3/4"></div>
<div class="h-8 bg-zinc-300 rounded w-1/2"></div>
<div class="h-4 bg-zinc-300 rounded w-3/4"></div>
</div>
</template>
@@ -33,22 +33,22 @@
<template v-else-if="prediction">
<h3
class="text-2xl font-black"
:class="prediction.action === 'fill_now' ? 'text-[#8B4860]' : prediction.action === 'wait' ? 'text-[#4A7C7E]' : 'text-[#9B8B6B]'"
:class="prediction.action === 'fill_now' ? 'text-mauve' : prediction.action === 'wait' ? 'text-teal' : 'text-tan'"
>
{{ actionLabel }}
</h3>
<div class="w-full h-2 bg-[#eeeae5] rounded-full overflow-hidden">
<div class="w-full h-2 bg-zinc-200 rounded-full overflow-hidden">
<div
class="h-full rounded-full transition-all"
:class="prediction.action === 'fill_now' ? 'bg-[#8B4860]' : 'bg-[#4A7C7E]'"
:class="prediction.action === 'fill_now' ? 'bg-mauve' : 'bg-teal'"
:style="{ width: prediction.confidence_score + '%' }"
></div>
</div>
<p class="text-sm text-[#89726c] leading-relaxed">{{ prediction.reasoning }}</p>
<p class="text-sm text-zinc-500 leading-relaxed">{{ prediction.reasoning }}</p>
<div class="flex items-center gap-4 text-xs text-[#89726c] font-medium">
<div class="flex items-center gap-4 text-xs text-zinc-500 font-medium">
<span>Avg: {{ prediction.current_avg }}p</span>
<span>Confidence: {{ prediction.confidence_label }}</span>
<span v-if="prediction.predicted_change_pence">
@@ -59,9 +59,9 @@
<!-- Empty state (placeholder for gated view) -->
<template v-else>
<h3 class="text-2xl font-black text-[#8B4860]">Fill up now</h3>
<div class="h-2 bg-[#eeeae5] rounded-full"><div class="h-full bg-[#8B4860] w-4/5 rounded-full"></div></div>
<p class="text-sm text-[#89726c]">Prices in your area are rising best to fill up today.</p>
<h3 class="text-2xl font-black text-mauve">Fill up now</h3>
<div class="h-2 bg-zinc-200 rounded-full"><div class="h-full bg-mauve w-4/5 rounded-full"></div></div>
<p class="text-sm text-zinc-500">Prices in your area are rising best to fill up today.</p>
</template>
</div>
</div>

View File

@@ -1,7 +1,7 @@
<template>
<div class="relative flex flex-col sm:flex-row gap-3 max-w-md w-full">
<div class="relative flex-1">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-[#89726c]">
<span class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500">
<iconify-icon icon="lucide:map-pin" style="font-size:1.25rem"></iconify-icon>
</span>
<input
@@ -9,13 +9,13 @@
@input="onInput"
type="text"
placeholder="Enter postcode, e.g. SW1A 1AA"
class="w-full h-14 pl-12 pr-4 bg-white border border-[#e5ded7] rounded-xl focus:outline-none focus:ring-2 focus:ring-[#bb5b3e] shadow-inner text-base"
class="w-full h-14 pl-12 pr-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-base"
/>
</div>
<button
@click="emit('search', postcode)"
:disabled="!postcode.trim()"
class="h-14 px-8 bg-[#bb5b3e] text-white rounded-xl font-bold text-base shadow-xl hover:bg-[#a34a31] transition-all disabled:opacity-50 disabled:cursor-not-allowed"
class="h-14 px-8 bg-accent text-white rounded-xl font-bold text-base shadow-xl hover:bg-accent-content transition-all disabled:opacity-50 disabled:cursor-not-allowed"
>
Find Prices
</button>

View File

@@ -1,16 +1,16 @@
<template>
<div class="flex items-center justify-between p-4 bg-white rounded-xl border border-[#e5ded7] hover:border-[#bb5b3e] transition-colors">
<div class="flex items-center justify-between p-4 bg-white rounded-xl border border-zinc-300 hover:border-accent transition-colors">
<div class="flex items-center gap-3 min-w-0">
<div class="w-10 h-10 rounded-lg bg-[#bb5b3e]/10 flex items-center justify-center flex-shrink-0">
<div class="w-10 h-10 rounded-lg bg-accent/10 flex items-center justify-center flex-shrink-0">
<iconify-icon
:icon="station.is_supermarket ? 'lucide:shopping-cart' : 'lucide:fuel'"
style="font-size:1.25rem"
class="text-[#bb5b3e]"
class="text-accent"
></iconify-icon>
</div>
<div class="min-w-0">
<p class="font-bold text-[#4a3f3b] truncate">{{ station.name }}</p>
<p class="text-xs text-[#89726c]">{{ station.distance_km.toFixed(1) }} km away · Updated {{ updatedAgo }}</p>
<p class="font-bold text-zinc-800 truncate">{{ station.name }}</p>
<p class="text-xs text-zinc-500">{{ station.distance_km.toFixed(1) }} km away · Updated {{ updatedAgo }}</p>
</div>
</div>
<div class="text-right flex-shrink-0 ml-4">
@@ -28,10 +28,10 @@ const props = defineProps({
})
const priceColor = computed(() => {
if (!props.lowestPrice) return 'text-[#4a3f3b]'
if (props.station.price_pence === props.lowestPrice) return 'text-[#22c55e]'
if (props.station.price_pence > props.lowestPrice + 500) return 'text-[#ef4444]'
return 'text-[#4a3f3b]'
if (!props.lowestPrice) return 'text-zinc-800'
if (props.station.price_pence === props.lowestPrice) return 'text-status-good'
if (props.station.price_pence > props.lowestPrice + 500) return 'text-status-bad'
return 'text-zinc-800'
})
const updatedAgo = computed(() => {

View File

@@ -9,8 +9,8 @@
:class="[
'px-4 py-1.5 rounded-full text-sm font-bold transition-colors',
currentSort === option.value
? 'bg-[#bb5b3e] text-white'
: 'bg-white border border-[#e5ded7] text-[#89726c] hover:border-[#bb5b3e]'
? 'bg-accent text-white'
: 'bg-white border border-zinc-300 text-zinc-500 hover:border-accent'
]"
>
{{ option.label }}
@@ -18,7 +18,7 @@
</div>
<!-- Count -->
<p class="text-sm text-[#89726c] font-medium">
<p class="text-sm text-zinc-500 font-medium">
{{ stations.length }} station{{ stations.length !== 1 ? 's' : '' }} found
</p>

View File

@@ -40,5 +40,39 @@ export function useAuth() {
fetched.value = false
}
return { user, loading, isAuthenticated, userTier, isPaidTier, fetchUser, clearUser }
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,
}
}

View File

@@ -4,6 +4,10 @@ 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' },
@@ -14,6 +18,16 @@ const routes = [
{ 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' },
],
},
],
},
]

View File

@@ -1,149 +1,395 @@
<template>
<div class="min-h-screen bg-[#f5ede5]">
<div class="min-h-screen bg-zinc-100">
<!-- Navigation -->
<nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4 md:px-12">
<nav class="fixed top-0 w-full z-50 bg-zinc-50 border-b border-zinc-300 px-6 py-4 md:px-12">
<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>
<RouterLink class="flex items-center gap-3" to="/">
<div class="w-10 h-10 md:w-12 md:h-12 rounded-lg bg-accent flex items-center justify-center shadow-md">
<iconify-icon class="text-white text-xl md:text-2xl" icon="lucide:fuel"></iconify-icon>
</div>
<span class="text-2xl font-black tracking-tighter text-[#bb5b3e]">FuelAlert</span>
<span class="text-2xl md:text-3xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
</RouterLink>
<div class="hidden md:flex items-center gap-10">
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#how-it-works">How it Works</a>
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#features">Features</a>
<a class="text-sm font-semibold text-zinc-500 hover:text-accent transition-colors" href="#pricing">Pricing</a>
</div>
<div class="flex items-center gap-4">
<template v-if="isAuthenticated">
<RouterLink to="/account" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">Account</RouterLink>
<RouterLink class="text-sm font-bold text-zinc-500 hover:text-zinc-800" to="/dashboard">Dashboard</RouterLink>
</template>
<template v-else>
<a href="/login" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">Login</a>
<a href="/register" class="bg-[#bb5b3e] text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-[#a34a31] transition-all">Get Started</a>
<a class="text-sm font-bold text-zinc-500 hover:text-zinc-800" href="/login">Login</a>
<a class="bg-accent text-white px-6 py-2.5 rounded-full text-sm font-bold shadow-lg hover:bg-primary-dark transition-all transform hover:scale-105 active:scale-95" href="/register">Get Started</a>
</template>
</div>
</div>
</nav>
<!-- Hero -->
<section class="relative pt-36 pb-16 px-6">
<div class="max-w-2xl mx-auto text-center space-y-6">
<div class="inline-flex items-center gap-2 px-3 py-1 bg-[#bb5b3e]/10 text-[#bb5b3e] rounded-full text-xs font-bold uppercase tracking-wider">
<section class="relative pt-40 pb-24 px-6 hero-gradient overflow-hidden">
<div class="max-w-7xl mx-auto grid lg:grid-cols-2 gap-16 items-center">
<div class="space-y-8">
<div class="inline-flex items-center gap-2 px-3 py-1 bg-accent/10 text-accent rounded-full text-xs font-bold uppercase tracking-wider">
<iconify-icon icon="lucide:sparkles"></iconify-icon>
Save up to £250/year on fuel
</div>
<h1 class="text-5xl md:text-6xl font-black text-[#4a3f3b] leading-tight tracking-tighter">
Stop Overpaying <span class="text-[#bb5b3e]">for Fuel.</span>
<h1 class="text-5xl md:text-7xl font-black font-display text-zinc-800 leading-[1.1] tracking-tighter">
Stop Overpaying <br><span class="text-accent">for Fuel.</span>
</h1>
<p class="text-lg text-[#89726c] max-w-lg mx-auto">Find the cheapest petrol near you and know the best time to fill up.</p>
<div class="flex justify-center">
<SearchBar @search="onSearch" />
</div>
<p v-if="stationError" class="text-sm text-red-500 font-medium">
{{ Object.values(stationError).flat().join(' ') }}
<p class="text-xl text-zinc-500 max-w-lg leading-relaxed">
Join 50,000+ UK drivers using real-time insights to find the cheapest petrol and time their fill-ups perfectly.
</p>
</div>
</section>
<!-- Results -->
<section v-if="hasSearched" class="px-6 pb-24">
<div class="max-w-4xl mx-auto space-y-6">
<!-- Fuel type selector -->
<div class="flex gap-2 flex-wrap">
<button
v-for="fuel in fuelOptions"
:key="fuel.value"
@click="changeFuelType(fuel.value)"
:class="[
'px-4 py-1.5 rounded-full text-sm font-bold transition-colors',
currentFuelType === fuel.value
? 'bg-[#4a3f3b] text-white'
: 'bg-white border border-[#e5ded7] text-[#89726c] hover:border-[#4a3f3b]'
]"
<div class="flex flex-col sm:flex-row gap-3 max-w-md">
<div class="relative flex-1">
<iconify-icon class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 text-xl" icon="lucide:map-pin"></iconify-icon>
<input
class="w-full h-14 pl-12 pr-4 bg-white border border-zinc-300 rounded-xl focus:outline-none focus:ring-2 focus:ring-accent shadow-inner text-lg"
placeholder="Enter Postcode"
type="text"
>
{{ fuel.label }}
</div>
<button class="h-14 px-8 bg-accent text-white rounded-xl font-bold text-lg shadow-xl hover:bg-primary-dark transition-all">
Find Prices
</button>
</div>
<div class="grid lg:grid-cols-3 gap-6">
<!-- Map + List (2/3 width) -->
<div class="lg:col-span-2 space-y-4">
<LeafletMap :stations="stations" />
<div class="flex items-center gap-4 pt-4">
<div class="flex -space-x-2">
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=1">
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=2">
<img alt="User" class="w-8 h-8 rounded-full border-2 border-white" src="https://api.dicebear.com/7.x/avataaars/svg?seed=3">
</div>
<span class="text-sm text-zinc-500 font-medium italic">"Saved me £12 on my first tank!"</span>
</div>
</div>
<!-- Visual mockup card -->
<div class="relative">
<div class="absolute -inset-4 bg-accent/5 rounded-[2.5rem] blur-2xl"></div>
<div class="relative glass-card p-6 rounded-[2rem] shadow-2xl space-y-4 max-w-md mx-auto transform rotate-2">
<div class="flex justify-between items-center mb-4">
<div class="flex items-center gap-2">
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
</div>
<span class="font-black text-accent">FuelAlert</span>
</div>
<div class="text-xs font-bold text-zinc-500">SW1A 1AA</div>
</div>
<div class="bg-zinc-50 p-4 rounded-xl border border-zinc-300 shadow-sm">
<p class="text-[10px] font-bold uppercase tracking-widest text-zinc-500 mb-1">Recommendation</p>
<h3 class="text-2xl font-black font-display text-mauve">Fill up now</h3>
<div class="mt-2 h-1.5 w-full bg-zinc-200 rounded-full overflow-hidden">
<div class="h-full bg-mauve w-[80%]"></div>
</div>
</div>
<template v-if="stationsLoading">
<div class="space-y-2">
<div v-for="i in 5" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-[#e5ded7]"></div>
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
<span class="font-bold text-sm">Tesco Superstore</span>
<span class="font-black text-status-good">142.9p</span>
</div>
<div class="flex justify-between items-center p-3 bg-white rounded-lg border border-zinc-200">
<span class="font-bold text-sm">Shell V-Power</span>
<span class="font-black text-zinc-500">148.9p</span>
</div>
</template>
<template v-else>
<StationList
:stations="stations"
:current-sort="currentSort"
@sort="changeSort"
/>
</template>
</div>
<!-- Prediction (1/3 width) -->
<div>
<PredictionCard
:prediction="prediction"
:loading="predictionLoading"
:is-paid-tier="isPaidTier"
/>
</div>
</div>
</div>
</section>
<!-- How It Works -->
<section id="how-it-works" class="py-24 px-6 bg-zinc-50">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16 space-y-4">
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">Smart Savings in 3 Steps</h2>
<p class="text-zinc-500 text-lg max-w-2xl mx-auto">Stop guessing when to fill up. Our engine analyzes thousands of data points daily to save you money.</p>
</div>
<div class="grid md:grid-cols-3 gap-12">
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:search"></iconify-icon>
</div>
<h3 class="text-2xl font-bold font-display">1. Search</h3>
<p class="text-zinc-500">Enter your postcode or location to find every forecourt within a 520 mile radius instantly.</p>
</div>
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:trending-up"></iconify-icon>
</div>
<h3 class="text-2xl font-bold font-display">2. Get Advice</h3>
<p class="text-zinc-500">Our AI compares local prices against national wholesale trends to give you a Fill Up / Wait recommendation.</p>
</div>
<div class="text-center space-y-4">
<div class="w-16 h-16 bg-accent/10 text-accent rounded-2xl flex items-center justify-center mx-auto text-3xl">
<iconify-icon icon="lucide:wallet"></iconify-icon>
</div>
<h3 class="text-2xl font-bold font-display">3. Fill Up Smart</h3>
<p class="text-zinc-500">Navigate to the cheapest station and fill up with confidence knowing you've secured the best price.</p>
</div>
</div>
</div>
</section>
<!-- Features -->
<section id="features" class="py-24 px-6">
<div class="max-w-7xl mx-auto">
<div class="grid lg:grid-cols-2 gap-20 items-center">
<div class="order-2 lg:order-1">
<div class="grid grid-cols-2 gap-6">
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon class="text-3xl text-accent" icon="lucide:zap"></iconify-icon>
<h4 class="font-bold text-lg font-display">Real-Time Prices</h4>
<p class="text-sm text-zinc-500">Verified daily prices from thousands of UK forecourts.</p>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon class="text-3xl text-accent" icon="lucide:calendar"></iconify-icon>
<h4 class="font-bold text-lg font-display">Timing Predictions</h4>
<p class="text-sm text-zinc-500">Proprietary 14-day forecasts for petrol and diesel trends.</p>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon class="text-3xl text-accent" icon="lucide:shopping-bag"></iconify-icon>
<h4 class="font-bold text-lg font-display">Supermarket Anchors</h4>
<p class="text-sm text-zinc-500">Track local supermarkets to find the absolute lowest base price.</p>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl space-y-3">
<iconify-icon class="text-3xl text-accent" icon="lucide:bell-ring"></iconify-icon>
<h4 class="font-bold text-lg font-display">Smart Price Alerts</h4>
<p class="text-sm text-zinc-500">Get notified when local prices drop below your set target.</p>
</div>
</div>
</div>
<div class="order-1 lg:order-2 space-y-8">
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800">The ultimate fuel companion.</h2>
<p class="text-lg text-zinc-500">Whether you're a daily commuter, a delivery professional, or just planning a weekend road trip, FuelAlert gives you the edge at the pump.</p>
<ul class="space-y-4">
<li class="flex items-center gap-3 font-bold">
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
Coverage for 98% of UK Forecourts
</li>
<li class="flex items-center gap-3 font-bold">
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
Hyper-local Map Visualization
</li>
<li class="flex items-center gap-3 font-bold">
<iconify-icon class="text-accent" icon="lucide:check-circle-2"></iconify-icon>
Historic Price Benchmarking
</li>
</ul>
<button class="inline-flex items-center gap-2 text-accent font-black text-lg group">
Explore all features
<iconify-icon class="group-hover:translate-x-1 transition-transform" icon="lucide:arrow-right"></iconify-icon>
</button>
</div>
</div>
</div>
</section>
<!-- Pricing -->
<section id="pricing" class="py-24 px-6 bg-zinc-50">
<div class="max-w-7xl mx-auto">
<div class="text-center mb-16">
<h2 class="text-4xl md:text-5xl font-black font-display text-zinc-800 mb-4">Pricing for every driver</h2>
<p class="text-zinc-500 text-lg">Save hundreds for less than the cost of a coffee.</p>
</div>
<div class="grid sm:grid-cols-2 lg:grid-cols-4 gap-6">
<!-- Free -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Free</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">£0</span>
<span class="text-zinc-500 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Basic Search</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Daily Updates</li>
<li class="text-sm flex gap-2 text-zinc-500"><iconify-icon class="text-zinc-300" icon="lucide:x"></iconify-icon> No Alerts</li>
</ul>
<a class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Get Started</a>
</div>
<!-- Basic -->
<div class="bg-white border border-zinc-300 p-8 rounded-3xl flex flex-col h-full">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Basic</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">£0.99</span>
<span class="text-zinc-500 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Ad-free Experience</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 14-day Trend Data</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> 3 Daily Price Alerts</li>
</ul>
<a class="w-full py-3 px-4 border border-zinc-300 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Select Basic</a>
</div>
<!-- Plus -->
<div class="bg-white border-2 border-accent p-8 rounded-3xl flex flex-col h-full relative">
<div class="absolute -top-4 left-1/2 -translate-x-1/2 bg-accent text-white px-4 py-1 rounded-full text-[10px] font-black uppercase tracking-widest whitespace-nowrap">Most Popular</div>
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Plus</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black text-accent">£2.49</span>
<span class="text-zinc-500 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2 font-bold"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Supermarket Anchor</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Priority Price Alerts</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-location tracking</li>
</ul>
<a class="w-full py-3 px-4 bg-accent text-white rounded-xl text-center font-bold shadow-lg hover:bg-primary-dark transition-all" href="/register">Join Plus</a>
</div>
<!-- Pro -->
<div class="bg-zinc-800 border border-zinc-800 p-8 rounded-3xl flex flex-col h-full text-white">
<div class="mb-8">
<h4 class="text-xl font-bold font-display mb-2">Pro</h4>
<div class="flex items-baseline gap-1">
<span class="text-4xl font-black">£3.99</span>
<span class="text-zinc-400 text-sm">/mo</span>
</div>
</div>
<ul class="space-y-4 mb-8 flex-1">
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:sparkles"></iconify-icon> AI Price Predictions</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Multi-Vehicle Fleet</li>
<li class="text-sm flex gap-2"><iconify-icon class="text-accent" icon="lucide:check"></iconify-icon> Exportable Price History</li>
</ul>
<a class="w-full py-3 px-4 bg-white text-zinc-800 rounded-xl text-center font-bold hover:bg-zinc-100 transition-colors" href="/register">Go Pro</a>
</div>
</div>
</div>
</section>
<!-- Testimonials -->
<section class="py-24 px-6">
<div class="max-w-7xl mx-auto">
<div class="flex flex-col md:flex-row gap-12 items-center">
<div class="md:w-1/3">
<h2 class="text-4xl font-black font-display text-zinc-800 mb-4">Loved by commuters.</h2>
<div class="flex items-center gap-1 text-status-warn mb-4 text-xl">
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
<iconify-icon icon="lucide:star"></iconify-icon>
</div>
<p class="text-zinc-500">Join thousands of UK drivers saving every single month.</p>
</div>
<div class="md:w-2/3 grid sm:grid-cols-2 gap-6">
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
"I used to just go to the station on my way home. Now I check FuelAlert and realise there's a station 2 miles away that's 5p cheaper! Over a month, it adds up to a free tank per year."
<div class="mt-4 flex items-center gap-3 not-italic">
<img alt="James R." class="w-10 h-10 rounded-full" src="https://api.dicebear.com/7.x/avataaars/svg?seed=John">
<div>
<p class="font-bold text-sm">James R.</p>
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Daily Commuter</p>
</div>
</div>
</div>
<div class="p-6 bg-zinc-50 border border-zinc-300 rounded-2xl shadow-sm italic text-zinc-800">
"The predictions are eerily accurate. I was going to fill up Friday, but FuelAlert said 'Hold on' for Monday. Sure enough, prices dropped at my local Tesco by 3p. Brilliant."
<div class="mt-4 flex items-center gap-3 not-italic">
<img alt="Sarah M." class="w-10 h-10 rounded-full" src="https://api.dicebear.com/7.x/avataaars/svg?seed=Sarah">
<div>
<p class="font-bold text-sm">Sarah M.</p>
<p class="text-[10px] text-zinc-500 uppercase font-bold tracking-widest">Delivery Driver</p>
</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- CTA -->
<section class="py-24 px-6 bg-accent text-white text-center">
<div class="max-w-3xl mx-auto space-y-8">
<h2 class="text-4xl md:text-5xl font-black font-display leading-tight">Ready to outsmart the pumps?</h2>
<p class="text-xl text-white/80">Sign up for free today and never pay over the odds for fuel again.</p>
<div class="flex flex-col sm:flex-row gap-4 justify-center">
<a class="bg-white text-accent px-10 py-4 rounded-xl text-lg font-black shadow-2xl hover:bg-zinc-100 transition-all" href="/register">Create Free Account</a>
<a class="bg-transparent border-2 border-white/30 text-white px-10 py-4 rounded-xl text-lg font-bold hover:bg-white/10 transition-all" href="#">Watch Demo Video</a>
</div>
</div>
</section>
<!-- Footer -->
<footer class="bg-zinc-50 border-t border-zinc-300 pt-16 pb-8 px-6">
<div class="max-w-7xl mx-auto grid grid-cols-2 md:grid-cols-4 gap-12 mb-12">
<div class="col-span-2 md:col-span-1 space-y-4">
<RouterLink class="flex items-center gap-2" to="/">
<div class="w-8 h-8 rounded bg-accent flex items-center justify-center">
<iconify-icon class="text-white" icon="lucide:fuel"></iconify-icon>
</div>
<span class="text-xl font-black font-display tracking-tighter text-accent">FuelAlert</span>
</RouterLink>
<p class="text-sm text-zinc-500 leading-relaxed">
Helping UK drivers save money at the pump since 2021. Real-time data, smarter choices.
</p>
<div class="flex gap-4">
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:twitter"></iconify-icon>
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:facebook"></iconify-icon>
<iconify-icon class="text-2xl text-zinc-500 hover:text-accent cursor-pointer transition-colors" icon="mdi:instagram"></iconify-icon>
</div>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Product</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a class="hover:text-accent transition-colors" href="#pricing">Pricing</a></li>
<li><a class="hover:text-accent transition-colors" href="#features">Features</a></li>
<li><a class="hover:text-accent transition-colors" href="#">FuelAlert Pro</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Enterprise API</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Resources</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a class="hover:text-accent transition-colors" href="#">Market Insights</a></li>
<li><a class="hover:text-accent transition-colors" href="#">How We Track</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Help Center</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Driver Safety</a></li>
</ul>
</div>
<div class="space-y-4">
<h5 class="font-black uppercase text-xs text-zinc-800 tracking-widest">Legal</h5>
<ul class="space-y-2 text-sm text-zinc-500">
<li><a class="hover:text-accent transition-colors" href="#">Privacy Policy</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Terms of Service</a></li>
<li><a class="hover:text-accent transition-colors" href="#">Cookie Settings</a></li>
</ul>
</div>
</div>
<div class="max-w-7xl mx-auto pt-8 border-t border-zinc-300 flex flex-col md:flex-row justify-between items-center gap-4 text-[10px] font-bold uppercase tracking-widest text-zinc-500">
<p>© 2024 FuelAlert UK Limited. All Rights Reserved.</p>
<p>Data provided by official UK retail price transparency schemes.</p>
</div>
</footer>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { RouterLink } from 'vue-router'
import { useAuth } from '../composables/useAuth.js'
import { useStations } from '../composables/useStations.js'
import { usePrediction } from '../composables/usePrediction.js'
import SearchBar from '../components/SearchBar.vue'
import LeafletMap from '../components/LeafletMap.vue'
import StationList from '../components/StationList.vue'
import PredictionCard from '../components/PredictionCard.vue'
const { isAuthenticated, isPaidTier } = useAuth()
const { stations, loading: stationsLoading, error: stationError, search } = useStations()
const { prediction, loading: predictionLoading, fetch: fetchPrediction } = usePrediction()
const hasSearched = ref(false)
const currentSort = ref('price')
const currentFuelType = ref('petrol')
const lastPostcode = ref('')
const fuelOptions = [
{ label: 'Petrol (E10)', value: 'petrol' },
{ label: 'Diesel', value: 'diesel' },
{ label: 'Premium Unleaded', value: 'e5' },
{ label: 'Premium Diesel', value: 'b7_premium' },
]
async function onSearch(postcode) {
lastPostcode.value = postcode
hasSearched.value = true
await Promise.all([
search({ postcode, fuelType: currentFuelType.value, sort: currentSort.value }),
fetchPrediction(),
])
}
async function changeSort(sort) {
currentSort.value = sort
if (lastPostcode.value) {
await search({ postcode: lastPostcode.value, fuelType: currentFuelType.value, sort })
}
}
async function changeFuelType(fuelType) {
currentFuelType.value = fuelType
if (lastPostcode.value) {
await search({ postcode: lastPostcode.value, fuelType, sort: currentSort.value })
}
}
const { isAuthenticated } = useAuth()
</script>

View File

@@ -1,19 +1,65 @@
<template>
<div class="min-h-screen bg-[#f5ede5] flex flex-col">
<div class="min-h-screen bg-zinc-100 flex flex-col">
<!-- Top nav -->
<nav class="fixed top-0 w-full z-50 bg-[#faf6f3] border-b border-[#e5ded7] px-6 py-4">
<nav class="fixed top-0 w-full z-50 bg-zinc-50 border-b border-zinc-300 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">
<div class="w-10 h-10 rounded-lg bg-accent 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>
<span class="text-2xl font-black tracking-tighter text-accent">FuelAlert</span>
</RouterLink>
<div class="flex items-center gap-4">
<RouterLink to="/" class="text-sm font-bold text-[#89726c] hover:text-[#4a3f3b]">
<RouterLink class="text-sm font-bold text-zinc-500 hover:text-zinc-800" to="/">
Find fuel
</RouterLink>
<span class="text-sm text-[#89726c]">{{ user?.email }}</span>
<!-- User dropdown -->
<div ref="dropdownRef" class="relative">
<button
@click="dropdownOpen = !dropdownOpen"
class="w-9 h-9 rounded-full bg-accent flex items-center justify-center text-white text-sm font-black hover:bg-accent-content transition-colors"
:aria-expanded="dropdownOpen ? 'true' : 'false'"
aria-haspopup="true"
>
{{ userInitials }}
</button>
<Transition
enter-active-class="transition ease-out duration-100"
enter-from-class="transform opacity-0 scale-95"
enter-to-class="transform opacity-100 scale-100"
leave-active-class="transition ease-in duration-75"
leave-from-class="transform opacity-100 scale-100"
leave-to-class="transform opacity-0 scale-95"
>
<div
v-show="dropdownOpen"
class="absolute right-0 top-full mt-2 w-64 bg-white border border-zinc-300 rounded-2xl shadow-lg overflow-hidden z-50"
>
<div class="px-4 py-3 border-b border-zinc-300">
<p class="text-sm font-black text-zinc-800">{{ user?.name }}</p>
<p class="text-xs text-zinc-500 truncate">{{ user?.email }}</p>
</div>
<div class="py-1">
<RouterLink
to="/dashboard/settings"
@click="dropdownOpen = false"
class="flex items-center gap-3 px-4 py-2.5 text-sm font-bold text-zinc-500 hover:bg-zinc-50 hover:text-zinc-800 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-zinc-500 hover:bg-zinc-50 hover:text-zinc-800 transition-colors"
>
<iconify-icon icon="lucide:log-out"></iconify-icon>
Log out
</button>
</div>
</div>
</Transition>
</div>
</div>
</div>
</nav>
@@ -27,9 +73,9 @@
: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="$route.path === item.to
? 'bg-[#bb5b3e] text-white'
: 'text-[#89726c] hover:bg-white hover:text-[#4a3f3b]'"
:class="isActive(item.to)
? 'bg-accent text-white'
: 'text-zinc-500 hover:bg-white hover:text-zinc-800'"
>
<iconify-icon :icon="item.icon"></iconify-icon>
{{ item.label }}
@@ -46,15 +92,60 @@
</template>
<script setup>
import { RouterLink, RouterView, useRoute } from 'vue-router'
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
import { useAuth } from '../../composables/useAuth.js'
const { user } = useAuth()
const $route = useRoute()
const { user, logout } = useAuth()
const route = useRoute()
const router = useRouter()
const dropdownOpen = ref(false)
const dropdownRef = ref(null)
function handleClickOutside(event) {
if (dropdownRef.value && !dropdownRef.value.contains(event.target)) {
dropdownOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
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() {
dropdownOpen.value = false
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>

View File

@@ -1,8 +1,8 @@
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-black text-[#4a3f3b]">Welcome back{{ user ? ', ' + user.name : '' }}</h1>
<p class="text-[#89726c] mt-1">Your FuelAlert dashboard.</p>
<h1 class="text-2xl font-black text-zinc-800">Welcome back{{ user ? ', ' + user.name : '' }}</h1>
<p class="text-zinc-500 mt-1">Your FuelAlert dashboard.</p>
</div>
<div class="grid sm:grid-cols-3 gap-4">
@@ -10,18 +10,18 @@
v-for="item in quickLinks"
:key="item.to"
:to="item.to"
class="p-6 bg-white rounded-2xl border border-[#e5ded7] hover:border-[#bb5b3e] transition-colors space-y-3"
class="p-6 bg-white rounded-2xl border border-zinc-300 hover:border-accent transition-colors space-y-3"
>
<iconify-icon :icon="item.icon" class="text-[#bb5b3e] text-2xl"></iconify-icon>
<p class="font-bold text-[#4a3f3b]">{{ item.label }}</p>
<p class="text-sm text-[#89726c]">{{ item.description }}</p>
<iconify-icon :icon="item.icon" class="text-accent text-2xl"></iconify-icon>
<p class="font-bold text-zinc-800">{{ item.label }}</p>
<p class="text-sm text-zinc-500">{{ item.description }}</p>
</RouterLink>
</div>
<div class="p-6 bg-white rounded-2xl border border-[#e5ded7] space-y-2">
<p class="text-sm font-bold uppercase tracking-widest text-[#89726c]">Your plan</p>
<p class="text-xl font-black text-[#4a3f3b] capitalize">{{ userTier }}</p>
<a v-if="userTier === 'free'" href="/pricing" class="inline-block text-sm font-bold text-[#bb5b3e] hover:underline">
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-2">
<p class="text-sm font-bold uppercase tracking-widest text-zinc-500">Your plan</p>
<p class="text-xl font-black text-zinc-800 capitalize">{{ userTier }}</p>
<a v-if="userTier === 'free'" class="inline-block text-sm font-bold text-accent hover:underline" href="/pricing">
Upgrade for alerts + predictions
</a>
</div>

View File

@@ -1,13 +1,13 @@
<template>
<div class="space-y-6 max-w-lg">
<h1 class="text-2xl font-black text-[#4a3f3b]">Preferences</h1>
<h1 class="text-2xl font-black text-zinc-800">Preferences</h1>
<form @submit.prevent="save" class="space-y-5 p-6 bg-white rounded-2xl border border-[#e5ded7]">
<form class="space-y-5 p-6 bg-white rounded-2xl border border-zinc-300" @submit.prevent="save">
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Default fuel type</label>
<label class="text-sm font-bold text-zinc-800">Default fuel type</label>
<select
v-model="form.preferred_fuel_type"
class="w-full h-12 px-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
class="w-full h-12 px-4 bg-zinc-50 border border-zinc-300 rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
>
<option value="petrol">Petrol (E10)</option>
<option value="diesel">Diesel (B7)</option>
@@ -19,13 +19,13 @@
</div>
<div class="space-y-2">
<label class="text-sm font-bold text-[#4a3f3b]">Home postcode</label>
<label class="text-sm font-bold text-zinc-800">Home postcode</label>
<input
v-model="form.postcode"
type="text"
placeholder="e.g. SW1A 1AA"
maxlength="8"
class="w-full h-12 px-4 bg-[#faf6f3] border border-[#e5ded7] rounded-xl font-medium text-[#4a3f3b] focus:outline-none focus:ring-2 focus:ring-[#bb5b3e]"
class="w-full h-12 px-4 bg-zinc-50 border border-zinc-300 rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
/>
</div>
@@ -33,7 +33,7 @@
<button
type="submit"
:disabled="saving"
class="px-8 py-3 bg-[#bb5b3e] text-white rounded-xl font-bold hover:bg-[#a34a31] transition-all disabled:opacity-50"
class="px-8 py-3 bg-accent text-white rounded-xl font-bold hover:bg-accent-content transition-all disabled:opacity-50"
>
{{ saving ? 'Saving…' : 'Save preferences' }}
</button>

View File

@@ -1,12 +1,12 @@
<template>
<div class="space-y-6">
<h1 class="text-2xl font-black text-[#4a3f3b]">Saved Stations</h1>
<h1 class="text-2xl font-black text-zinc-800">Saved Stations</h1>
<div v-if="loading" class="space-y-2">
<div v-for="i in 3" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-[#e5ded7]"></div>
<div v-for="i in 3" :key="i" class="h-16 bg-white rounded-xl animate-pulse border border-zinc-300"></div>
</div>
<div v-else-if="savedStations.length === 0" class="p-8 bg-white rounded-2xl border border-[#e5ded7] text-center text-[#89726c]">
<div v-else-if="savedStations.length === 0" class="p-8 bg-white rounded-2xl border border-zinc-300 text-center text-zinc-500">
<iconify-icon icon="lucide:bookmark" class="text-3xl mb-2"></iconify-icon>
<p class="font-medium">No saved stations yet.</p>
<p class="text-sm mt-1">Search for fuel and bookmark stations to see them here.</p>
@@ -16,11 +16,11 @@
<div
v-for="station in savedStations"
:key="station.station_id"
class="flex items-center justify-between p-4 bg-white rounded-xl border border-[#e5ded7]"
class="flex items-center justify-between p-4 bg-white rounded-xl border border-zinc-300"
>
<div>
<p class="font-bold text-[#4a3f3b]">{{ station.name }}</p>
<p class="text-sm text-[#89726c]">{{ station.postcode }}</p>
<p class="font-bold text-zinc-800">{{ station.name }}</p>
<p class="text-sm text-zinc-500">{{ station.postcode }}</p>
</div>
<button
@click="remove(station.station_id)"

View File

@@ -0,0 +1,61 @@
<template>
<div class="space-y-6 max-w-lg">
<div class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-5">
<h2 class="text-lg font-black text-zinc-800">Appearance</h2>
<p class="text-sm text-zinc-500">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-accent bg-zinc-50'
: 'border-zinc-300 hover:border-zinc-500'"
>
<iconify-icon :icon="option.icon" class="text-2xl text-accent"></iconify-icon>
<span class="text-sm font-bold text-zinc-800">{{ option.label }}</span>
</button>
</div>
<p class="text-xs text-zinc-500">
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>

View File

@@ -0,0 +1,163 @@
<template>
<div class="space-y-6 max-w-lg">
<!-- Profile form -->
<form class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-5" @submit.prevent="saveProfile">
<h2 class="text-lg font-black text-zinc-800">Profile information</h2>
<div class="space-y-2">
<label class="text-sm font-bold text-zinc-800">Full name</label>
<input
v-model="profileForm.name"
type="text"
:class="profileErrors.name ? 'border-red-400' : 'border-zinc-300'"
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<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-zinc-800">Email address</label>
<input
v-model="profileForm.email"
type="email"
:class="profileErrors.email ? 'border-red-400' : 'border-zinc-300'"
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<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-accent text-white rounded-xl font-bold hover:bg-accent-content 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-zinc-300 space-y-4">
<h2 class="text-lg font-black text-zinc-800">Delete account</h2>
<p class="text-sm text-zinc-500">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-zinc-800">Are you sure?</h3>
<p class="text-sm text-zinc-500">Enter your password to confirm account deletion.</p>
<div class="space-y-2">
<label class="text-sm font-bold text-zinc-800">Password</label>
<input
v-model="deletePassword"
type="password"
:class="deleteError ? 'border-red-400' : 'border-zinc-300'"
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-red-400"
/>
<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-zinc-300 rounded-xl text-sm font-bold text-zinc-500 hover:bg-zinc-50 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>

View File

@@ -0,0 +1,322 @@
<template>
<div class="space-y-6 max-w-lg">
<!-- Password change -->
<form class="p-6 bg-white rounded-2xl border border-zinc-300 space-y-5" @submit.prevent="savePassword">
<h2 class="text-lg font-black text-zinc-800">Change password</h2>
<div class="space-y-2">
<label class="text-sm font-bold text-zinc-800">Current password</label>
<input
v-model="passwordForm.current_password"
type="password"
:class="passwordErrors.current_password ? 'border-red-400' : 'border-zinc-300'"
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<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-zinc-800">New password</label>
<input
v-model="passwordForm.password"
type="password"
:class="passwordErrors.password ? 'border-red-400' : 'border-zinc-300'"
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<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-zinc-800">Confirm new password</label>
<input
v-model="passwordForm.password_confirmation"
type="password"
:class="passwordErrors.password_confirmation ? 'border-red-400' : 'border-zinc-300'"
class="w-full h-12 px-4 bg-zinc-50 border rounded-xl font-medium text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<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-accent text-white rounded-xl font-bold hover:bg-accent-content 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-zinc-300 space-y-4">
<div class="flex items-center justify-between">
<div>
<h2 class="text-lg font-black text-zinc-800">Two-factor authentication</h2>
<p class="text-sm text-zinc-500 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-zinc-50 text-zinc-500'"
>
{{ twoFactorEnabled ? 'ON' : 'OFF' }}
</span>
</div>
<!-- 2FA enabling flow -->
<div v-if="enablingTwoFactor" class="space-y-4 border-t border-zinc-300 pt-4">
<p class="text-sm font-bold text-zinc-800">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-zinc-500 uppercase tracking-widest">Or enter setup key manually</p>
<code class="text-xs bg-zinc-50 px-3 py-2 rounded-lg font-mono text-zinc-800 break-all block">{{ setupData.secretKey }}</code>
</div>
</div>
<div v-if="!twoFactorEnabled" class="space-y-2">
<label class="text-sm font-bold text-zinc-800">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="confirmError ? 'border-red-400' : 'border-zinc-300'"
class="w-36 h-12 px-4 bg-zinc-50 border rounded-xl font-mono text-center text-zinc-800 focus:outline-none focus:ring-2 focus:ring-accent"
/>
<button
@click="confirmTwoFactor"
:disabled="confirmCode.length !== 6 || confirming"
class="px-6 py-3 bg-accent text-white rounded-xl text-sm font-bold hover:bg-accent-content 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-zinc-500 hover:text-zinc-800"
>
Cancel
</button>
</div>
<!-- Recovery codes (when 2FA enabled and not mid-setup) -->
<div v-if="twoFactorEnabled && !enablingTwoFactor" class="border-t border-zinc-300 pt-4 space-y-3">
<div class="flex items-center justify-between">
<p class="text-sm font-bold text-zinc-800">Recovery codes</p>
<button
@click="toggleRecoveryCodes"
class="text-xs font-bold text-accent hover:underline"
>
{{ showRecoveryCodes ? 'Hide' : 'Show' }}
</button>
</div>
<div v-if="showRecoveryCodes" class="space-y-2">
<div class="grid grid-cols-2 gap-1 bg-zinc-50 rounded-xl p-4">
<code
v-for="code in recoveryCodes"
:key="code"
class="text-xs font-mono text-zinc-800"
>{{ code }}</code>
</div>
<button
@click="regenRecoveryCodes"
:disabled="regenLoading"
class="text-xs font-bold text-zinc-500 hover:text-zinc-800"
>
{{ regenLoading ? 'Regenerating…' : 'Regenerate codes' }}
</button>
</div>
</div>
<!-- Action buttons -->
<div class="flex gap-3 border-t border-zinc-300 pt-4">
<button
v-if="!twoFactorEnabled && !enablingTwoFactor"
@click="enableTwoFactor"
:disabled="tfaActionLoading"
class="px-6 py-2.5 bg-accent text-white rounded-xl text-sm font-bold hover:bg-accent-content 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-zinc-300 rounded-xl text-sm font-bold text-zinc-500 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>

View File

@@ -0,0 +1,45 @@
<template>
<div class="space-y-6">
<div>
<h1 class="text-2xl font-black text-zinc-800">Settings</h1>
<p class="text-zinc-500 mt-1">Manage your account and preferences.</p>
</div>
<div class="flex gap-6">
<!-- Settings sub-nav -->
<aside class="w-44 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-accent text-white'
: 'text-zinc-500 hover:bg-white hover:text-zinc-800'"
>
<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>

View File

@@ -1,20 +0,0 @@
<div class="flex items-start max-md:flex-col">
<div class="me-10 w-full pb-4 md:w-[220px]">
<flux:navlist aria-label="{{ __('Settings') }}">
<flux:navlist.item :href="route('profile.edit')" wire:navigate>{{ __('Profile') }}</flux:navlist.item>
<flux:navlist.item :href="route('security.edit')" wire:navigate>{{ __('Security') }}</flux:navlist.item>
<flux:navlist.item :href="route('appearance.edit')" wire:navigate>{{ __('Appearance') }}</flux:navlist.item>
</flux:navlist>
</div>
<flux:separator class="md:hidden" />
<div class="flex-1 self-stretch max-md:pt-6">
<flux:heading>{{ $heading ?? '' }}</flux:heading>
<flux:subheading>{{ $subheading ?? '' }}</flux:subheading>
<div class="mt-5 w-full max-w-lg">
{{ $slot }}
</div>
</div>
</div>

View File

@@ -1,13 +0,0 @@
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Appearance settings') }}</flux:heading>
<x-settings.layout :heading="__('Appearance')" :subheading=" __('Update the appearance settings for your account')">
<flux:radio.group x-data variant="segmented" x-model="$flux.appearance">
<flux:radio value="light" icon="sun">{{ __('Light') }}</flux:radio>
<flux:radio value="dark" icon="moon">{{ __('Dark') }}</flux:radio>
<flux:radio value="system" icon="computer-desktop">{{ __('System') }}</flux:radio>
</flux:radio.group>
</x-settings.layout>
</section>

View File

@@ -1,34 +0,0 @@
<section class="mt-10 space-y-6">
<div class="relative mb-5">
<flux:heading>{{ __('Delete account') }}</flux:heading>
<flux:subheading>{{ __('Delete your account and all of its resources') }}</flux:subheading>
</div>
<flux:modal.trigger name="confirm-user-deletion">
<flux:button variant="danger" x-data="" x-on:click.prevent="$dispatch('open-modal', 'confirm-user-deletion')">
{{ __('Delete account') }}
</flux:button>
</flux:modal.trigger>
<flux:modal name="confirm-user-deletion" :show="$errors->isNotEmpty()" focusable class="max-w-lg">
<form method="POST" wire:submit="deleteUser" class="space-y-6">
<div>
<flux:heading size="lg">{{ __('Are you sure you want to delete your account?') }}</flux:heading>
<flux:subheading>
{{ __('Once your account is deleted, all of its resources and data will be permanently deleted. Please enter your password to confirm you would like to permanently delete your account.') }}
</flux:subheading>
</div>
<flux:input wire:model="password" :label="__('Password')" type="password" viewable />
<div class="flex justify-end space-x-2 rtl:space-x-reverse">
<flux:modal.close>
<flux:button variant="filled">{{ __('Cancel') }}</flux:button>
</flux:modal.close>
<flux:button variant="danger" type="submit">{{ __('Delete account') }}</flux:button>
</div>
</form>
</flux:modal>
</section>

View File

@@ -1,36 +0,0 @@
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Profile settings') }}</flux:heading>
<x-settings.layout :heading="__('Profile')" :subheading="__('Update your name and email address')">
<form wire:submit="updateProfileInformation" class="my-6 w-full space-y-6">
<flux:input wire:model="name" :label="__('Name')" type="text" required autofocus autocomplete="name" />
<div>
<flux:input wire:model="email" :label="__('Email')" type="email" required autocomplete="email" />
@if ($this->hasUnverifiedEmail)
<div>
<flux:text class="mt-4">
{{ __('Your email address is unverified.') }}
<flux:link class="text-sm cursor-pointer" wire:click.prevent="resendVerificationNotification">
{{ __('Click here to re-send the verification email.') }}
</flux:link>
</flux:text>
</div>
@endif
</div>
<div class="flex items-center gap-4">
<flux:button variant="primary" type="submit">{{ __('Save') }}</flux:button>
</div>
</form>
@if ($this->showDeleteUser)
<livewire:settings.delete-user-form />
@endif
</x-settings.layout>
</section>

View File

@@ -1,233 +0,0 @@
<section class="w-full">
@include('partials.settings-heading')
<flux:heading class="sr-only">{{ __('Security settings') }}</flux:heading>
<x-settings.layout :heading="__('Update password')" :subheading="__('Ensure your account is using a long, random password to stay secure')">
<form method="POST" wire:submit="updatePassword" class="mt-6 space-y-6">
<flux:input
wire:model="current_password"
:label="__('Current password')"
type="password"
required
autocomplete="current-password"
viewable
/>
<flux:input
wire:model="password"
:label="__('New password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<flux:input
wire:model="password_confirmation"
:label="__('Confirm password')"
type="password"
required
autocomplete="new-password"
viewable
/>
<div class="flex items-center gap-4">
<flux:button variant="primary" type="submit" data-test="update-password-button">{{ __('Save') }}</flux:button>
</div>
</form>
@if ($canManageTwoFactor)
<section class="mt-12">
<flux:heading>{{ __('Two-factor authentication') }}</flux:heading>
<flux:subheading>{{ __('Manage your two-factor authentication settings') }}</flux:subheading>
<div class="flex flex-col w-full mx-auto space-y-6 text-sm" wire:cloak>
@if ($twoFactorEnabled)
<div class="space-y-4">
<flux:text>
{{ __('You will be prompted for a secure, random pin during login, which you can retrieve from the TOTP-supported application on your phone.') }}
</flux:text>
<div class="flex justify-start">
<flux:button
variant="danger"
wire:click="disable"
>
{{ __('Disable 2FA') }}
</flux:button>
</div>
<livewire:settings.two-factor.recovery-codes :$requiresConfirmation/>
</div>
@else
<div class="space-y-4">
<flux:text variant="subtle">
{{ __('When you enable two-factor authentication, you will be prompted for a secure pin during login. This pin can be retrieved from a TOTP-supported application on your phone.') }}
</flux:text>
<flux:button
variant="primary"
wire:click="enable"
>
{{ __('Enable 2FA') }}
</flux:button>
</div>
@endif
</div>
</section>
<flux:modal
name="two-factor-setup-modal"
class="max-w-md md:min-w-md"
@close="closeModal"
wire:model="showModal"
>
<div class="space-y-6">
<div class="flex flex-col items-center space-y-4">
<div class="p-0.5 w-auto rounded-full border border-stone-100 dark:border-stone-600 bg-white dark:bg-stone-800 shadow-sm">
<div class="p-2.5 rounded-full border border-stone-200 dark:border-stone-600 overflow-hidden bg-stone-100 dark:bg-stone-200 relative">
<div class="flex items-stretch absolute inset-0 w-full h-full divide-x [&>div]:flex-1 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<div class="flex flex-col items-stretch absolute w-full h-full divide-y [&>div]:flex-1 inset-0 divide-stone-200 dark:divide-stone-300 justify-around opacity-50">
@for ($i = 1; $i <= 5; $i++)
<div></div>
@endfor
</div>
<flux:icon.qr-code class="relative z-20 dark:text-accent-foreground"/>
</div>
</div>
<div class="space-y-2 text-center">
<flux:heading size="lg">{{ $this->modalConfig['title'] }}</flux:heading>
<flux:text>{{ $this->modalConfig['description'] }}</flux:text>
</div>
</div>
@if ($showVerificationStep)
<div class="space-y-6">
<div class="flex flex-col items-center space-y-3 justify-center">
<flux:otp
name="code"
wire:model="code"
length="6"
label="OTP Code"
label:sr-only
class="mx-auto"
/>
</div>
<div class="flex items-center space-x-3">
<flux:button
variant="outline"
class="flex-1"
wire:click="resetVerification"
>
{{ __('Back') }}
</flux:button>
<flux:button
variant="primary"
class="flex-1"
wire:click="confirmTwoFactor"
x-bind:disabled="$wire.code.length < 6"
>
{{ __('Confirm') }}
</flux:button>
</div>
</div>
@else
@error('setupData')
<flux:callout variant="danger" icon="x-circle" heading="{{ $message }}"/>
@enderror
<div class="flex justify-center">
<div class="relative w-64 overflow-hidden border rounded-lg border-stone-200 dark:border-stone-700 aspect-square">
@empty($qrCodeSvg)
<div class="absolute inset-0 flex items-center justify-center bg-white dark:bg-stone-700 animate-pulse">
<flux:icon.loading/>
</div>
@else
<div x-data class="flex items-center justify-center h-full p-4">
<div
class="bg-white p-3 rounded"
:style="($flux.appearance === 'dark' || ($flux.appearance === 'system' && $flux.dark)) ? 'filter: invert(1) brightness(1.5)' : ''"
>
{!! $qrCodeSvg !!}
</div>
</div>
@endempty
</div>
</div>
<div>
<flux:button
:disabled="$errors->has('setupData')"
variant="primary"
class="w-full"
wire:click="showVerificationIfNecessary"
>
{{ $this->modalConfig['buttonText'] }}
</flux:button>
</div>
<div class="space-y-4">
<div class="relative flex items-center justify-center w-full">
<div class="absolute inset-0 w-full h-px top-1/2 bg-stone-200 dark:bg-stone-600"></div>
<span class="relative px-2 text-sm bg-white dark:bg-stone-800 text-stone-600 dark:text-stone-400">
{{ __('or, enter the code manually') }}
</span>
</div>
<div
class="flex items-center space-x-2"
x-data="{
copied: false,
async copy() {
try {
await navigator.clipboard.writeText('{{ $manualSetupKey }}');
this.copied = true;
setTimeout(() => this.copied = false, 1500);
} catch (e) {
console.warn('Could not copy to clipboard');
}
}
}"
>
<div class="flex items-stretch w-full border rounded-xl dark:border-stone-700">
@empty($manualSetupKey)
<div class="flex items-center justify-center w-full p-3 bg-stone-100 dark:bg-stone-700">
<flux:icon.loading variant="mini"/>
</div>
@else
<input
type="text"
readonly
value="{{ $manualSetupKey }}"
class="w-full p-3 bg-transparent outline-none text-stone-900 dark:text-stone-100"
/>
<button
@click="copy()"
class="px-3 transition-colors border-l cursor-pointer border-stone-200 dark:border-stone-600"
>
<flux:icon.document-duplicate x-show="!copied" variant="outline"></flux:icon>
<flux:icon.check
x-show="copied"
variant="solid"
class="text-green-500"
></flux:icon>
</button>
@endempty
</div>
</div>
</div>
@endif
</div>
</flux:modal>
@endif
</x-settings.layout>
</section>

View File

@@ -1,89 +0,0 @@
<div
class="py-6 space-y-6 border shadow-sm rounded-xl border-zinc-200 dark:border-white/10"
wire:cloak
x-data="{ showRecoveryCodes: false }"
>
<div class="px-6 space-y-2">
<div class="flex items-center gap-2">
<flux:icon.lock-closed variant="outline" class="size-4"/>
<flux:heading size="lg" level="3">{{ __('2FA recovery codes') }}</flux:heading>
</div>
<flux:text variant="subtle">
{{ __('Recovery codes let you regain access if you lose your 2FA device. Store them in a secure password manager.') }}
</flux:text>
</div>
<div class="px-6">
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<flux:button
x-show="!showRecoveryCodes"
icon="eye"
icon:variant="outline"
variant="primary"
@click="showRecoveryCodes = true;"
aria-expanded="false"
aria-controls="recovery-codes-section"
>
{{ __('View recovery codes') }}
</flux:button>
<flux:button
x-show="showRecoveryCodes"
icon="eye-slash"
icon:variant="outline"
variant="primary"
@click="showRecoveryCodes = false"
aria-expanded="true"
aria-controls="recovery-codes-section"
>
{{ __('Hide recovery codes') }}
</flux:button>
@if (filled($recoveryCodes))
<flux:button
x-show="showRecoveryCodes"
icon="arrow-path"
variant="filled"
wire:click="regenerateRecoveryCodes"
>
{{ __('Regenerate codes') }}
</flux:button>
@endif
</div>
<div
x-show="showRecoveryCodes"
x-transition
id="recovery-codes-section"
class="relative overflow-hidden"
x-bind:aria-hidden="!showRecoveryCodes"
>
<div class="mt-3 space-y-3">
@error('recoveryCodes')
<flux:callout variant="danger" icon="x-circle" heading="{{$message}}"/>
@enderror
@if (filled($recoveryCodes))
<div
class="grid gap-1 p-4 font-mono text-sm rounded-lg bg-zinc-100 dark:bg-white/5"
role="list"
aria-label="{{ __('Recovery codes') }}"
>
@foreach($recoveryCodes as $code)
<div
role="listitem"
class="select-text"
wire:loading.class="opacity-50 animate-pulse"
>
{{ $code }}
</div>
@endforeach
</div>
<flux:text variant="subtle" class="text-xs">
{{ __('Each recovery code can be used once to access your account and will be removed after use. If you need more, click Regenerate codes above.') }}
</flux:text>
@endif
</div>
</div>
</div>
</div>

View File

@@ -1,5 +0,0 @@
<div class="relative mb-6 w-full">
<flux:heading size="xl" level="1">{{ __('Settings') }}</flux:heading>
<flux:subheading size="lg" class="mb-6">{{ __('Manage your profile and account settings') }}</flux:subheading>
<flux:separator variant="subtle" />
</div>

View File

@@ -30,4 +30,8 @@ Route::middleware('auth:sanctum')->group(function (): void {
Route::get('/user/saved-stations', [UserController::class, 'savedStations']);
Route::post('/user/saved-stations', [UserController::class, 'saveStation']);
Route::delete('/user/saved-stations/{stationId}', [UserController::class, 'removeStation']);
Route::put('/user/profile', [UserController::class, 'updateProfile']);
Route::put('/user/password', [UserController::class, 'updatePassword']);
Route::delete('/user', [UserController::class, 'deleteAccount']);
});

View File

@@ -1,28 +0,0 @@
<?php
use App\Livewire\Settings\Appearance;
use App\Livewire\Settings\Profile;
use App\Livewire\Settings\Security;
use Illuminate\Support\Facades\Route;
use Laravel\Fortify\Features;
Route::middleware(['auth'])->group(function () {
Route::redirect('settings', 'settings/profile');
Route::livewire('settings/profile', Profile::class)->name('profile.edit');
});
Route::middleware(['auth', 'verified'])->group(function () {
Route::livewire('settings/appearance', Appearance::class)->name('appearance.edit');
Route::livewire('settings/security', Security::class)
->middleware(
when(
Features::canManageTwoFactorAuthentication()
&& Features::optionEnabled(Features::twoFactorAuthentication(), 'confirmPassword'),
['password.confirm'],
[],
),
)
->name('security.edit');
});

View File

@@ -2,8 +2,6 @@
use Illuminate\Support\Facades\Route;
require __DIR__.'/settings.php';
// Named dashboard route so route('dashboard') resolves; Vue Router handles rendering
Route::get('/dashboard', fn () => view('app'))->middleware(['auth', 'verified'])->name('dashboard');

View File

@@ -1,10 +1,95 @@
{
"version": 1,
"skills": {
"antfu": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "6935586ef3ab02e18747a4477d396e692bc96e8f28b0d95b374ffc37fce74cda"
},
"nuxt": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "9e8237ec92083db9a6f4c77d5e5bf4b1d9873d670671235bc75455ee1ca39c74"
},
"pinia": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "897b6b0982956f41f5cc85128ca638b625e1b3c8bc619e303cd1e7661c95b7de"
},
"pnpm": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "318f9fca2441a3e06fedc336e195dd18be5dbe901064e4fa71b76b40b096ab3a"
},
"slidev": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "4973bc1baf08fa1303f99ef3abce8ec3aedfb2637e4315de30a2421746b610e3"
},
"superdesign": {
"source": "superdesigndev/superdesign-skill",
"sourceType": "github",
"computedHash": "7208b5e2e1145d76abcd698b2a9085952d986022ec2d5d8b9f972106b1b2447b"
},
"tsdown": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "5d7a0732aa15849d7363d168c13eec9c4367c9e534397de3fc550d582ffea09b"
},
"turborepo": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "bb531fab09e44812e3dc41e77f08708fbb67984ed9421d3c6f54f741fe70c2a0"
},
"unocss": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "4b7a9f7b89994d5aef70b9d55cb7f65d6e80cea437d5786cd33bb5cf7231c7c4"
},
"vite": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "867ff66238f3152e9c494339ad08dc432dd0df5bcf5cc7a00b61a72b580eb908"
},
"vitepress": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "629d8ec4bcbf7cac86c15c3ca5f173933f01729bcb6396c0d74733755a896a41"
},
"vitest": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "669c7275f7e1379b06b2bcfa593603aead4c983b951c7e8edbc09640aa38d0a1"
},
"vue": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "9c241141e07e836e4f0537c63e8f929fba3767c2997250be38e699f19b75e3f2"
},
"vue-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "d7d22c8cb343583c3904692c4d1d7b50382945e433e4f6e053f4aabb9846cbc3"
},
"vue-router-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "e27384f4e6c8c70a612e76b74e4387efb8e291a6a1e3aa14a69102a4ce4b4654"
},
"vue-testing-best-practices": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "18c7d8f42f350f927e37de055e34c97b8cfb9f79c12cf942f7f3d2a0821057b5"
},
"vueuse-functions": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "fedfc78f035a88fe3e9d823f44725fe8981486c55b966c17fbd0d7c28339e49a"
},
"web-design-guidelines": {
"source": "antfu/skills",
"sourceType": "github",
"computedHash": "65a2e7d85753383ae0f88df15475d58b9e39723e9c4bb6891421d6144a85f79c"
}
}
}

View File

@@ -1,6 +1,7 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Laravel\Sanctum\Sanctum;
it('returns user preferences for authenticated user', function (): void {
@@ -65,3 +66,135 @@ it('rejects unauthenticated requests to user endpoints', function (): void {
$this->getJson('/api/user/preferences')->assertUnauthorized();
$this->getJson('/api/user/saved-stations')->assertUnauthorized();
});
// --- 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();
});

View File

@@ -1,75 +0,0 @@
<?php
use App\Models\User;
use Livewire\Livewire;
test('profile page is displayed', function () {
$this->actingAs($user = User::factory()->create());
$this->get(route('profile.edit'))->assertOk();
});
test('profile information can be updated', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test('pages::settings.profile')
->set('name', 'Test User')
->set('email', 'test@example.com')
->call('updateProfileInformation');
$response->assertHasNoErrors();
$user->refresh();
expect($user->name)->toEqual('Test User');
expect($user->email)->toEqual('test@example.com');
expect($user->email_verified_at)->toBeNull();
});
test('email verification status is unchanged when email address is unchanged', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test('pages::settings.profile')
->set('name', 'Test User')
->set('email', $user->email)
->call('updateProfileInformation');
$response->assertHasNoErrors();
expect($user->refresh()->email_verified_at)->not->toBeNull();
});
test('user can delete their account', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test('pages::settings.delete-user-modal')
->set('password', 'password')
->call('deleteUser');
$response
->assertHasNoErrors()
->assertRedirect('/');
expect($user->fresh())->toBeNull();
expect(auth()->check())->toBeFalse();
});
test('correct password must be provided to delete account', function () {
$user = User::factory()->create();
$this->actingAs($user);
$response = Livewire::test('pages::settings.delete-user-modal')
->set('password', 'wrong-password')
->call('deleteUser');
$response->assertHasErrors(['password']);
expect($user->fresh())->not->toBeNull();
});

View File

@@ -1,104 +0,0 @@
<?php
use App\Models\User;
use Illuminate\Support\Facades\Hash;
use Laravel\Fortify\Features;
use Livewire\Livewire;
beforeEach(function () {
$this->skipUnlessFortifyHas(Features::twoFactorAuthentication());
Features::twoFactorAuthentication([
'confirm' => true,
'confirmPassword' => true,
]);
});
test('security settings page can be rendered', function () {
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertOk()
->assertSee('Two-factor authentication')
->assertSee('Enable 2FA');
});
test('security settings page requires password confirmation when enabled', function () {
$user = User::factory()->create();
$response = $this->actingAs($user)
->get(route('security.edit'));
$response->assertRedirect(route('password.confirm'));
});
test('security settings page renders without two factor when feature is disabled', function () {
config(['fortify.features' => []]);
$user = User::factory()->create();
$this->actingAs($user)
->withSession(['auth.password_confirmed_at' => time()])
->get(route('security.edit'))
->assertOk()
->assertSee('Update password')
->assertDontSee('Two-factor authentication');
});
test('two factor authentication disabled when confirmation abandoned between requests', function () {
$user = User::factory()->create();
$user->forceFill([
'two_factor_secret' => encrypt('test-secret'),
'two_factor_recovery_codes' => encrypt(json_encode(['code1', 'code2'])),
'two_factor_confirmed_at' => null,
])->save();
$this->actingAs($user);
$component = Livewire::test('pages::settings.security');
$component->assertSet('twoFactorEnabled', false);
$this->assertDatabaseHas('users', [
'id' => $user->id,
'two_factor_secret' => null,
'two_factor_recovery_codes' => null,
]);
});
test('password can be updated', function () {
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
$this->actingAs($user);
$response = Livewire::test('pages::settings.security')
->set('current_password', 'password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$response->assertHasNoErrors();
expect(Hash::check('new-password', $user->refresh()->password))->toBeTrue();
});
test('correct password must be provided to update password', function () {
$user = User::factory()->create([
'password' => Hash::make('password'),
]);
$this->actingAs($user);
$response = Livewire::test('pages::settings.security')
->set('current_password', 'wrong-password')
->set('password', 'new-password')
->set('password_confirmation', 'new-password')
->call('updatePassword');
$response->assertHasErrors(['current_password']);
});