Skip to content

Authentication

This project uses session-based authentication, implemented with custom API endpoints in the backend and a simple, stateful approach in the frontend. The system is designed for a single-page application (SPA) using Vue and Pinia, with all authentication state managed via HTTP-only cookies.

Backend

Endpoints

All endpoints are relative to the backend API root (see VITE_BACKEND_URL).

MethodEndpointDescription
POST/loginLog in a user, set session cookie
POST/logoutLog out, invalidate session
POST/registerRegister a new user
GET/userGet current user info (401 if not logged in)
POST/forgot-passwordSend password reset email
POST/reset-passwordReset password using token
POST/email/verification-notificationSend email verification link
GET/email/verify/{id}/{hash}Verify user email

Example Flows

  • Login: POST /login with credentials, sets session cookie.
  • Get User: GET /user returns user info if authenticated.
  • Register: POST /register with user info, creates user and logs in.
  • Password Reset: POST /forgot-password to request, then POST /reset-password with token.
  • Email Verification: POST /email/verification-notification to send, GET /email/verify/{id}/{hash} to verify.

Backend File Descriptions

api/routes/web.php

Defines all authentication-related HTTP routes for the backend.

  • Maps HTTP endpoints to controller methods in AuthController.
  • Public routes: login, register, forgot/reset password.
  • Protected routes (require authentication): /user, email verification endpoints.
php
<?php

use App\Http\Controllers\AuthController;
use Illuminate\Support\Facades\Route;

Route::get('/heartbeat', function () {
    return now();
});

Route::post('/logout', [AuthController::class, 'logout']);
Route::post('/forgot-password', [AuthController::class, 'forgotPassword']);
Route::post('/reset-password', [AuthController::class, 'resetPassword']);

Route::group(['middleware' => 'auth:sanctum'], function () {
    Route::get('/user', [AuthController::class, 'user']);
    Route::post('/email/verification-notification', [AuthController::class, 'emailVerificationNotification'])
        ->name('verification.send');
    Route::get('/email/verify/{id}/{hash}', [AuthController::class, 'verifyEmail'])
        ->name('verification.verify');
});

api/app/Http/Controllers/AuthController.php

Implements all authentication logic for the backend.

  • Each public method corresponds to an endpoint in routes/web.php.
  • Handles login, logout, user info, password reset, and email verification.
  • Uses the User model and Laravel's built-in password/email verification features.
php
<?php

namespace App\Http\Controllers;

use App\Models\User;
use Illuminate\Auth\Events\PasswordReset;
use Illuminate\Foundation\Auth\EmailVerificationRequest;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;

class AuthController extends Controller
{
    public function user(Request $request)
    {
        Log::debug('Returning user', ['user_id' => $request->user()->id]);
        return $request->user();
    }

    public function emailVerificationNotification(Request $request)
    {
        if ($request->user()->hasVerifiedEmail()) {
            return new Response(status: 400);
        }
        Log::debug('Sending email verification mail', ['user_id' => $request->user()->id]);
        $request->user()->sendEmailVerificationNotification();
    }

    public function verifyEmail(EmailVerificationRequest $request)
    {
        // EmailVerificationRequest's authorize() method already checks:
        // 1. Authenticated user's ID matches route {id}
        // 2. sha1 of authenticated user's email matches route {hash}
        // If authorize() fails, a 403 is automatically returned.

        // Explicitly check the 'expires' and 'signature' query parameters.
        if (!$request->hasValidSignature()) {
            Log::debug('Email verification link is invalid or expired.', [
                'user_id' => $request->user()->id,
                'query_params' => $request->query()
            ]);
            return new Response('Invalid or expired verification link.', Response::HTTP_BAD_REQUEST);
        }

        if ($request->user()->hasVerifiedEmail()) {
            Log::debug('Email already verified.', ['user_id' => $request->user()->id]);
            // Returning a success status or a specific "already verified" message is acceptable.
        } else {
            Log::debug('Verifying email', ['user_id' => $request->user()->id]);
            $request->fulfill();
        }

        return new Response(status: Response::HTTP_OK);
    }

    public function forgotPassword(Request $request)
    {
        Log::debug('Validating input');
        $request->validate(['email' => 'required|email']);
        Log::debug('Sending reset link');
        $status = Password::sendResetLink(
            $request->only('email')
        );
        return $status;
    }

    public function resetPassword(Request $request)
    {
        Log::debug('Validating input for password reset');
        $request->validate([
            'token' => 'required',
            'email' => 'required|email',
            'password' => 'required|min:8|confirmed',
        ]);

        $status = Password::reset(
            $request->only('email', 'password', 'password_confirmation', 'token'),
            function (User $user, string $password) {
                Log::debug('Updating user password', ['user_id' => $user->id]);
                $user->forceFill([
                    'password' => Hash::make($password),
                ])->setRememberToken(Str::random(60));

                $user->save();

                event(new PasswordReset($user));

                Log::debug('Logging in user after password reset', ['user_id' => $user->id]);
                Auth::guard('web')->login($user);
            }
        );

        if ($status == Password::PASSWORD_RESET) {
            Log::info('Password reset successfully.', ['email' => $request->email]);
            return response()->json(['message' => trans($status)], Response::HTTP_OK);
        }

        Log::warning('Password reset failed.', ['email' => $request->email, 'status' => $status]);
        return response()->json(['message' => trans($status)], Response::HTTP_UNPROCESSABLE_ENTITY);
    }

    public function logout(Request $request)
    {
        Log::debug('Logging out');
        Auth::guard('web')->logout();
        Log::debug('Destroying session');
        if ($request->hasSession()) {
            $request->session()->invalidate();
            $request->session()->regenerateToken();
        }
        return new Response(status: 204);
    }
}

api/app/Models/User.php

Defines the User model.

  • Implements interfaces for email verification and password reset.
  • Sends custom email verification notifications.
  • Adds a verified attribute to the serialized user.
  • Used by the controller for all user-related operations.
php
<?php

namespace App\Models;

use App\Notifications\CustomVerifyEmail;
use Illuminate\Contracts\Auth\CanResetPassword;
use Illuminate\Contracts\Auth\MustVerifyEmail;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;
use Laravel\Sanctum\HasApiTokens;

class User extends Authenticatable implements CanResetPassword, MustVerifyEmail
{
    use HasApiTokens, HasFactory, Notifiable;

    public function sendEmailVerificationNotification()
    {
        $this->notify(new CustomVerifyEmail);
    }

    protected $appends = ['verified'];

    protected $fillable = [
        'name',
        'email',
        'password',
        'role',
        /* USER_FOREIGN_KEYS */
    ];

    protected $hidden = [
        'password',
        'remember_token',
        'two_factor_recovery_codes',
        'two_factor_secret',
        'two_factor_confirmed_at',
        'email_verified_at',
    ];

    protected $casts = [
        'email_verified_at' => 'datetime',
        'password' => 'hashed',
    ];

    public function isAdmin()
    {
        return $this->role == 'admin';
    }

    public function getVerifiedAttribute(): bool
    {
        return $this->hasVerifiedEmail();
    }
    /* USER_RELATIONS */
}

api/app/Notifications/CustomVerifyEmail.php

Customizes the email verification notification.

  • Overrides the default verification URL to point to the frontend.
  • Ensures email verification links work in an SPA context.
php
<?php

namespace App\Notifications;

use Illuminate\Auth\Notifications\VerifyEmail as BaseVerifyEmail;
use Illuminate\Bus\Queueable;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\URL;

class CustomVerifyEmail extends BaseVerifyEmail
{
    use Queueable;

    /**
     * Get the verification URL for the given notifiable.
     *
     * @param  mixed  $notifiable
     * @return string
     */
    protected function verificationUrl($notifiable)
    {
        // Generate the temporary signed URL
        $temporarySignedUrl = URL::temporarySignedRoute(
            'verification.verify',
            Carbon::now()->addMinutes(config('auth.verification.expire', 60)),
            ['id' => $notifiable->getKey(), 'hash' => sha1($notifiable->getEmailForVerification())]
        );

        // Get the configured app URLs
        $appUrl = Config::get('app.url', 'http://localhost');
        $uiUrl = Config::get('app.ui_url', 'http://localhost');

        // Replace the API URL with the UI URL
        return str_replace($appUrl, $uiUrl, $temporarySignedUrl);
    }

    /**
     * Get the mail representation of the notification.
     *
     * @param  mixed  $notifiable
     * @return MailMessage
     */
    public function toMail($notifiable)
    {
        $verificationUrl = $this->verificationUrl($notifiable);

        return (new MailMessage)
            ->subject('Verify Email Address')
            ->line('Please click the button below to verify your email address.')
            ->action('Verify Email Address', $verificationUrl)
            ->line('If you did not create an account, no further action is required.');
    }
}

Frontend

Axios Setup

Configures Axios to send credentials (cookies) with every request and sets the required headers for CSRF protection.

ts
axios.defaults.withCredentials = true
axios.defaults.withXSRFToken = true
axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'

API Helper

Centralizes all authentication-related API calls.

  • Each method corresponds to a backend endpoint.
  • Used by the Pinia store and components to interact with the backend authentication API.

src/helpers/api/AuthApi.ts:

ts
import type { User } from '@/models/User/Model'
import axios, { type AxiosResponse } from 'axios'

export interface RequestPasswordResetParams {
	email: string
}

export interface ResetPasswordParams {
	email: string
	token: string
	password: string
	password_confirmation: string
}

export default class AuthApi {
	async login({
		email,
		password,
		remember = false,
	}: {
		email: string
		password: string
		remember?: boolean
	}) {
		return axios.post(
			import.meta.env.VITE_BACKEND_URL + '/login',
			{ email, password, remember },
			{
				headers: {
					Accept: 'application/json',
				},
			},
		) as Promise<AxiosResponse<User>>
	}

	async logout() {
		return axios.post(import.meta.env.VITE_BACKEND_URL + '/logout')
	}

	async getUser() {
		return axios.get(import.meta.env.VITE_BACKEND_URL + '/user') as Promise<AxiosResponse<User>>
	}

	async register({
		email,
		password,
		password_confirmation,
	}: {
		email: string
		password: string
		password_confirmation: string
	}) {
		return axios.post(import.meta.env.VITE_BACKEND_URL + '/register', {
			email,
			password,
			password_confirmation,
			name: email,
		})
	}

	async verifyEmail({
		id,
		hash,
		expires,
		signature,
	}: {
		id: string
		hash: string
		expires: string
		signature: string
	}) {
		await axios.get(import.meta.env.VITE_BACKEND_URL + `/email/verify/${id}/${hash}`, {
			params: { expires, signature },
		})
		await this.getUser()
	}

	async resendEmailVerification() {
		return await axios.post(import.meta.env.VITE_BACKEND_URL + '/email/verification-notification')
	}

	async requestPasswordReset(params: RequestPasswordResetParams) {
		return axios.post(import.meta.env.VITE_BACKEND_URL + '/forgot-password', params)
	}

	async resetPassword(params: ResetPasswordParams) {
		return axios.post(import.meta.env.VITE_BACKEND_URL + '/reset-password', params)
	}
}

Pinia Store

Manages authentication state in the frontend.

  • Wraps API calls and exposes actions for login, logout, registration, password reset, and user fetching.
  • Acts as the single source of truth for authentication state in the SPA.

src/stores/Auth.ts:

ts
import AuthApi, {
	type RequestPasswordResetParams,
	type ResetPasswordParams,
} from '@/helpers/api/AuthApi'
import type { User } from '@/models/User/Model'
import { defineStore } from 'pinia'
const auth = new AuthApi()
const useAuthStore = defineStore('auth', {
	state: () => {
		return {
			user: null as User | null,
			userReturned: false,
			userRequest: null as Promise<any> | null,
		}
	},
	actions: {
		async login({
			email,
			password,
			remember = false,
		}: {
			email: string
			password: string
			remember?: boolean
		}) {
			await auth.login({ email, password, remember })
			await this.getUser()
		},
		async logout() {
			await auth.logout()
			this.user = null
		},
		async getUser() {
			try {
				if (!this.userRequest) {
					this.userRequest = auth.getUser()
				}
				this.user = (await this.userRequest).data
			} catch (error: any) {
				if (error.response?.status !== 401) throw error
			} finally {
				this.userRequest = null
			}
			this.userReturned = true
		},
		async register(params: { email: string; password: string; password_confirmation: string }) {
			await auth.register(params)
		},
		async requestPasswordReset(params: RequestPasswordResetParams) {
			return await auth.requestPasswordReset(params)
		},
		async resetPassword(params: ResetPasswordParams) {
			return await auth.resetPassword(params)
		},
	},
})

export { useAuthStore }

Route Guard

Protects routes based on authentication and email verification status.

  • Redirects users as needed based on their authentication and verification state.
  • Ensures only authenticated and (optionally) verified users can access protected parts of the SPA.

src/router/guards/authGuard.ts:

ts
import { useAuthStore } from '@/stores/Auth'
import type { RouteLocationNormalizedLoaded } from 'vue-router'

export default async (to: RouteLocationNormalizedLoaded) => {
	const auth = useAuthStore()

	// Allow user to visit unprotected routes
	if (to.meta.unprotected) {
		return true
	}

	// Get the user if it's not already loaded
	if (!auth.userReturned) {
		await auth.getUser()
	}

	// Is user is not logged in
	if (!auth.user) {
		// If user is not logged in allow access of user avoidant routes
		if (to.meta.avoidUser) {
			return true
		}

		// If user is not logged in, redirect to login
		return { name: 'login' }
	}

	// If user is logged in but not verified redirect to verify email
	if (!auth.user.verified && to.name !== 'verify-email-notice') {
		return { name: 'verify-email-notice' }
	}

	// If user is logged in prevent access of user avoidant routes
	if (to.meta.avoidUser) {
		return { name: 'root' }
	}

	// If user doesn't have required role, prevent access to route
	if (Array.isArray(to.meta.roles) && !to.meta.roles?.includes(auth.user.role)) {
		return { name: 'root' }
	}

	// If user is logged in, allow access to everything else
	return true
}

useAuth Composable

File: src/composables/useAuth.ts

Purpose:

  • Provides a Vue composable for global authentication state and error handling.
  • Automatically fetches the user on component mount.
  • Sets up a global Axios response interceptor to handle authentication errors and CSRF token mismatches.
  • If a 401 Unauthorized error is received and a user is present, it resets the user state and redirects to the login page.
  • If a 419 CSRF token mismatch occurs, it reloads the page to refresh the CSRF token.

How it fits:

  • Used in root or layout components to ensure the user state is always up-to-date and to provide robust error handling for all API requests.
  • Integrates with the Pinia AuthStore and Vue Router.

Full file:

ts
import { useAuthStore } from '@/stores/Auth'
import axios from 'axios'
import { onBeforeMount, onMounted } from 'vue'
import { useRouter } from 'vue-router'

export function useAuth() {
	const auth = useAuthStore()

	onBeforeMount(() => {
		auth.getUser()
	})

	onMounted(() => {
		const router = useRouter()
		axios.interceptors.response.use(
			async (response: any) => {
				return response
			},
			async (error: any) => {
				// If request returns 401 and user should be logged in
				// reset the user and redirect to login
				if (error.response?.status === 401 && auth.user) {
					auth.user = null
					auth.userReturned = false
					router.push('/login')
				}
				// If there's a CSRF token mismatch reload page
				else if (
					error.response?.status === 419 &&
					error.response.data.message === 'CSRF token mismatch.'
				) {
					document.location.reload()
				}

				return Promise.reject(error)
			},
		)
	})

	return { authStore: auth }
}

Authentication Views

The following Vue components implement the user interface for all authentication flows. Each view is self-contained and uses the shared FormInput.vue component for consistent form styling and error display.

src/views/Auth/Login.vue

Purpose:

  • Handles user login.
  • Displays validation errors.
  • Allows navigation to registration and password reset.
  • Uses the Pinia AuthStore for login logic.

Full file:

vue
<template>
	<div class="login">
		<Card class="login__card">
			<template #title>Sign in</template>
			<template #content>
				<form @submit.prevent="submit()">
					<FormInput
						label="Email"
						:error-message="formErrors?.email?.[0]">
						<template #prepend-label>
							<p class="login__input-container-login-tip">
								Login with pre-populated credentials. Your credentials won't work here as this is a
								test deployment.
							</p>
						</template>
						<InputText
							v-model="form.email"
							class="login__input" />
					</FormInput>
					<FormInput
						label="Password"
						:error-message="formErrors?.password?.[0]">
						<InputText
							v-model="form.password"
							class="login__input"
							type="password" />
					</FormInput>
					<div class="login__remember-forgot-container">
						<div class="login__remember-container">
							<Checkbox
								v-model="form.remember"
								input-id="remember"
								binary />
							<label
								for="remember"
								class="login__remember-label"
								>Remember me</label
							>
						</div>
						<router-link
							class="login__forgot-password"
							:to="{ name: 'forgot-password' }"
							>Forgot password?
						</router-link>
					</div>
					<Button
						class="login__login-button"
						label="Login"
						type="submit"
						:loading />
					<Button
						outlined
						label="Register"
						:disabled="loading"
						@click="router.push({ name: 'register' })" />
				</form>
			</template>
		</Card>
	</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/Auth'
import { useRouter } from 'vue-router'
import Button from 'primevue/button'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Checkbox from 'primevue/checkbox'
import FormInput from '@/components/FormInput.vue'

const form = ref({
	email: 'test@codecannon.dev',
	password: 'password',
	remember: false,
})
const formErrors = ref<{
	email?: string[]
	password?: string[]
} | null>(null)
const loading = ref(false)

const router = useRouter()
const auth = useAuthStore()

async function submit() {
	formErrors.value = null
	loading.value = true
	try {
		await auth.login(form.value)
	} catch (error: any) {
		if (error.response?.status === 422) {
			formErrors.value = error.response?.data?.errors
		}
		throw error
	} finally {
		loading.value = false
	}
	router.push('/')
}
</script>

<style scoped lang="scss">
.login {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	width: 100%;

	.login__card {
		width: 500px;
		overflow: hidden;

		form {
			display: flex;
			flex-direction: column;
			align-items: flex-start;
			gap: 8px;

			& > * {
				width: 100%;
			}

			.login__input-container-login-tip {
				font-size: 10px;
				color: var(--p-text-muted-color);
				font-style: italic;
				margin: 0;
			}

			.login__remember-forgot-container {
				display: flex;
				justify-content: space-between;
				align-items: center;
				width: 100%;
				margin-bottom: 16px;

				.login__remember-container {
					display: flex;
					align-items: center;
					gap: 8px;
					white-space: nowrap;

					.login__remember-label {
						cursor: pointer;
						font-size: 14px;
					}
				}

				.login__forgot-password {
					font-size: 14px;
					color: var(--primary-color);
					text-decoration: none;
				}
			}
		}
	}
}
</style>

src/views/Auth/Register.vue

Purpose:

  • Handles user registration.
  • Displays validation errors.
  • Allows navigation to login.
  • Uses the Pinia AuthStore for registration logic.

Full file:

vue
<template>
	<div class="register">
		<Card class="register__card">
			<template #title>Register</template>
			<template #content>
				<form @submit.prevent="submit()">
					<FormInput
						label="Email"
						:error-message="formErrors?.email?.[0]">
						<InputText
							v-model="form.email"
							@keyup.enter="submit" />
					</FormInput>
					<FormInput
						label="Password"
						:error-message="formErrors?.password?.[0]">
						<InputText
							v-model="form.password"
							type="password"
							@keyup.enter="submit" />
					</FormInput>
					<FormInput
						label="Password Confirmation"
						:error-message="formErrors?.password_confirmation?.[0]">
						<InputText
							v-model="form.password_confirmation"
							type="password"
							@keyup.enter="submit" />
					</FormInput>
					<Button
						class="register__register-button"
						label="Register"
						type="submit"
						:loading />
					<Divider />
					<Button
						outlined
						label="Login"
						:disabled="loading"
						@click="router.push({ name: 'login' })" />
				</form>
			</template>
		</Card>
	</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { useAuthStore } from '@/stores/Auth'
import { useRouter } from 'vue-router'
import Button from 'primevue/button'
import Card from 'primevue/card'
import InputText from 'primevue/inputtext'
import Divider from 'primevue/divider'
import FormInput from '@/components/FormInput.vue'

const form = ref({
	email: '',
	password: '',
	password_confirmation: '',
})
const formErrors = ref<{
	email?: string[]
	password?: string[]
	password_confirmation?: string[]
} | null>(null)
const loading = ref(false)

const router = useRouter()
const auth = useAuthStore()

async function submit() {
	loading.value = true
	try {
		await auth.register(form.value)
		await auth.getUser()
		router.push('/')
	} catch (error: any) {
		if (error.response?.status === 422) {
			formErrors.value = error.response?.data?.errors
		}
	} finally {
		loading.value = false
	}
}
</script>

<style scoped lang="scss">
.register {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	width: 100%;

	.register__card {
		width: 500px;
		overflow: hidden;

		&:deep(.p-card-content) {
			form {
				gap: 10px;
				display: flex;
				flex-direction: column;

				.register__register-button {
					margin-top: 16px;
				}
			}
		}
	}
}
</style>

src/views/Auth/ForgotPassword.vue

Purpose:

  • Handles password reset requests.
  • Displays confirmation when the reset email is sent.
  • Uses the Pinia AuthStore for password reset request logic.

Full file:

vue
<template>
	<div class="forgot-password">
		<Card class="forgot-password__card">
			<template #title>Forgot Password</template>
			<template #content>
				<form
					v-if="!emailSent"
					@submit.prevent="requestPasswordReset()">
					<p>Enter your email and we'll send you a password reset link</p>
					<FormInput
						label="Email"
						:error-message="formErrors?.email?.[0]">
						<InputText
							v-model="form.email"
							class="forgot-password__input" />
					</FormInput>
					<Button
						label="Request password reset"
						type="submit"
						:loading />
				</form>
				<p v-else>
					An email has been sent to your email address with a password reset link. Please check your
					email.
				</p>
				<Divider />
				<Button
					:disabled="loading"
					outlined
					label="Login"
					@click="router.push({ name: 'login' })" />
			</template>
		</Card>
	</div>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import { useAuthStore } from '@/stores/Auth'
import type { RequestPasswordResetParams } from '@/helpers/api/AuthApi'
import InputText from 'primevue/inputtext'
import Divider from 'primevue/divider'
import FormInput from '@/components/FormInput.vue'
import { useRouter } from 'vue-router'

const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const form = ref<RequestPasswordResetParams>({
	email: '',
})
const formErrors = ref<{ email?: string[] } | null>(null)
const emailSent = ref(false)

async function requestPasswordReset() {
	formErrors.value = null
	loading.value = true
	try {
		await auth.requestPasswordReset(form.value)
		emailSent.value = true
	} catch (error: any) {
		if (error?.response?.status === 422 && error.response.data.errors) {
			formErrors.value = error.response.data.errors
		} else {
			throw error
		}
	} finally {
		loading.value = false
	}
}
</script>

<style scoped lang="scss">
.forgot-password {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	width: 100%;

	.forgot-password__card {
		width: 500px;
		overflow: hidden;

		&:deep(.p-card-content) {
			display: flex;
			flex-direction: column;
			p {
				margin-bottom: 16px;
			}
		}

		form {
			display: flex;
			flex-direction: column;
			align-items: flex-start;
			gap: 8px;

			& > * {
				width: 100%;
			}
		}
	}
}
</style>

src/views/Auth/ResetPassword.vue

Purpose:

  • Handles password reset.
  • Updates the user's password
  • Uses the Pinia AuthStore for password reset logic.

Full file:

vue
<template>
	<div class="reset-password">
		<Card class="reset-password__card">
			<template #title>Reset Password</template>
			<template #content>
				<p>Enter new password to reset it</p>
				<form @submit.prevent="resetPassword()">
					<FormInput
						label="Password"
						:error-message="formErrors?.password?.[0]">
						<InputText
							v-model="form.password"
							class="reset-password__input"
							type="password"
							@keyup.enter="resetPassword()" />
					</FormInput>
					<FormInput
						label="Password Confirmation"
						:error-message="formErrors?.password_confirmation?.[0]">
						<InputText
							v-model="form.password_confirmation"
							class="reset-password__input"
							type="password"
							@keyup.enter="resetPassword()" />
					</FormInput>
					<Button
						label="Request password reset"
						type="submit"
						:loading />
					<Divider />
					<Button
						outlined
						label="Login"
						:disabled="loading"
						@click="router.push({ name: 'login' })" />
				</form>
			</template>
		</Card>
	</div>
</template>

<script setup lang="ts">
import { ref, onBeforeMount } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import { useAuthStore } from '@/stores/Auth'
import type { ResetPasswordParams } from '@/helpers/api/AuthApi'
import { useRoute, useRouter } from 'vue-router'
import InputText from 'primevue/inputtext'
import { useToast } from 'primevue/usetoast'
import Divider from 'primevue/divider'
import FormInput from '@/components/FormInput.vue'

const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const toast = useToast()
const form = ref<ResetPasswordParams>({
	token: '',
	email: '',
	password: '',
	password_confirmation: '',
})
const formErrors = ref<{
	email?: string[]
	token?: string[]
	password?: string[]
	password_confirmation?: string[]
} | null>(null)

async function resetPassword() {
	formErrors.value = null
	loading.value = true
	try {
		await auth.resetPassword(form.value)
		toast.add({
			severity: 'success',
			summary: 'Password reset',
			detail: 'Password reset successfully',
			life: 3000,
		})
		await auth.getUser()
		router.push({ name: 'root' })
	} catch (error: any) {
		if (error?.response?.status === 422 && error.response.data.errors) {
			formErrors.value = error.response.data.errors
		} else if (error?.response?.status === 422 && error.response.data.message) {
			formErrors.value = { password: [error.response.data.message] }
		} else {
			throw error
		}
	} finally {
		loading.value = false
	}
}

onBeforeMount(() => {
	form.value.token = route.query.token as string
	form.value.email = route.query.email as string
})
</script>

<style scoped lang="scss">
.reset-password {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	width: 100%;

	.reset-password__card {
		width: 500px;

		&:deep(.p-card-content) {
			display: flex;
			flex-direction: column;
			p {
				margin-bottom: 16px;
			}
		}

		form {
			display: flex;
			flex-direction: column;
			align-items: flex-start;
			gap: 8px;

			& > * {
				width: 100%;
			}
		}
	}
}
</style>

src/views/Auth/VerifyEmail.vue

Purpose:

  • Informs the user to verify their email.
  • Allows resending the verification email.
  • Allows logout.

Full file:

vue
<template>
	<div class="verify-email">
		<Card class="verify-email__card">
			<template #title>Verify Email</template>
			<template #content>
				<p>Please verify your email to continue</p>
				<Button
					:loading
					label="Resend Email"
					@click="resendEmail()" />
				<Divider />
				<Button
					:disabled="loading"
					outlined
					label="Logout"
					@click="router.push({ name: 'logout' })" />
			</template>
		</Card>
	</div>
</template>

<script setup lang="ts">
import AuthApi from '@/helpers/api/AuthApi'
import { ref } from 'vue'
import Button from 'primevue/button'
import Card from 'primevue/card'
import Divider from 'primevue/divider'
import { useRouter } from 'vue-router'

const router = useRouter()
const loading = ref(false)

async function resendEmail() {
	loading.value = true
	try {
		await new AuthApi().resendEmailVerification()
	} finally {
		loading.value = false
	}
}
</script>

<style scoped lang="scss">
.verify-email {
	display: flex;
	align-items: center;
	justify-content: center;
	height: 100vh;
	width: 100%;

	.verify-email__card {
		width: 500px;
		overflow: hidden;

		&:deep(.p-card-content) {
			display: flex;
			flex-direction: column;

			p {
				margin-bottom: 16px;
			}
		}
	}
}
</style>

Customizing User Authorization

User authorization in this project is enforced at three layers: backend policy, backend controller, and frontend route configuration. Together, these layers ensure that only users with the appropriate permissions can access or modify user resources.

1. app/Policies/UserPolicy.php

Defines per-action authorization logic for user resources. By default:

  • Only admins can create users.
  • Admins or the user themselves can update or delete a user.
  • All users can list and view users.

Example: Allow all users to update any user (not just admins or self):

diff
@@
-    public function update(User $authUser, User $user): bool
-    {
-        return $authUser->isAdmin() || $user->id == $authUser->id;
-    }
+    public function update(User $authUser, User $user): bool
+    {
+        return true; // All authenticated users can update any user
+    }

2. app/Http/Controllers/UserController.php

Uses Gate::authorize to enforce the policy for each action. Each controller method maps to a policy method (e.g., update calls Gate::authorize('update', $user)).

Example: Remove authorization for listing users (make it public):

diff
@@
-    public function list(Request $request)
-    {
-        Gate::authorize('list', User::class);
-
-        return $this->crudControllerHelper->list($request);
-    }
+    public function list(Request $request)
+    {
+        // No authorization check: anyone can list users
+        return $this->crudControllerHelper->list($request);
+    }

3. ui/src/router/index.ts (User Routes)

Frontend routes for user management are restricted to admins using the meta.roles: ['admin'] property. This ensures only admins can access user list, create, and edit pages in the SPA.

Example: Allow all authenticated users to access the user list in the frontend:

diff
{
    path: '/users',
    children: [
        {
            path: '',
            name: 'users-list',
            component: UserList,
+            // Remove meta.roles to allow all authenticated users
-            meta: {
-                roles: ['admin'],
-            },
        },
    ],
},

How These Layers Work Together

  • Policy: Defines the core authorization rules for each action on the backend.
  • Controller: Enforces the policy for each API endpoint, ensuring backend security.
  • Frontend Routes: Restrict access to certain pages in the SPA, providing a user-friendly experience and hiding unauthorized UI.

To change authorization behavior:

  • Update the policy for backend rules.
  • Update the controller if you want to relax or remove backend checks.
  • Update the frontend routes to control which users see which pages.

This layered approach ensures robust, flexible, and user-friendly authorization throughout the application.

Email Verification

  • After registration, users must verify their email.
  • Verification links explicitly check for valid signatures and expiration. Expired or invalid links return a 400 Bad Request response.
  • The frontend can trigger a new verification email via POST /email/verification-notification.
  • The verification link in the email points to the frontend, which then calls the backend verification endpoint.

Password Reset

  • Users can request a password reset link via POST /forgot-password.
  • The reset link in the email points to the frontend, which collects the token and submits it to POST /reset-password with the new password.
  • Invalid or expired tokens return a 422 Unprocessable Entity response with an appropriate error message.
  • The password reset URL is customized in AuthServiceProvider.php to point directly to the frontend SPA:
php
// app/Providers/AuthServiceProvider.php
ResetPassword::createUrlUsing(function ($user, string $token) {
    return config('app.ui_url').'/reset-password?token='.$token.'&email='.$user->getEmailForPasswordReset();
});

What is NOT used

  • No /sanctum/csrf-cookie endpoint is called anywhere in the codebase.
  • No API tokens, OAuth, or JWTs are used.
  • No third-party authentication providers.

Summary

  • Session-based authentication using secure cookies.
  • Custom endpoints for all auth flows.
  • No CSRF cookie endpoint or token-based auth.
  • Frontend and backend are tightly coupled via these endpoints and session cookies.

If you need to add new authentication flows, follow the pattern above: add a backend endpoint, call it from the frontend API helper, and update the Pinia store as needed.