Appearance
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
).
Method | Endpoint | Description |
---|---|---|
POST | /login | Log in a user, set session cookie |
POST | /logout | Log out, invalidate session |
POST | /register | Register a new user |
GET | /user | Get current user info (401 if not logged in) |
POST | /forgot-password | Send password reset email |
POST | /reset-password | Reset password using token |
POST | /email/verification-notification | Send 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.