Authentication
CPR uses a Bearer token authentication system backed by a Laravel API. The frontend handles login, token storage, auth state hydration, route protection, password reset, logout, and branch switching.
Login Flow
Step 1: User Submits Credentials
The user enters a username and password on the /login page. The AuthService.login() method sends a POST request to the API:
// From auth.sevice.ts
async login(username: string, password: string): Promise<LoginResponse> {
try {
const config = useRuntimeConfig();
const base = (config.public?.API_URL as string) || '';
const url = `${base}/auth/login`;
const res: ApiResponse<LoginResponse> = await $fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ username, password }),
});
return res.data;
} catch (err: unknown) {
const fetchError = err as FetchError;
const message =
fetchError.data?.message ?? fetchError.message ?? 'Something went wrong';
Sentry.captureException(err);
throw new Error(message);
}
}Step 2: Token Stored in sessionStorage
On successful login, the response contains a user object and a token. These are persisted to sessionStorage:
// Login page stores auth state
const response = await authService.login(username, password);
// Store in sessionStorage for persistence across page refreshes
sessionStorage.setItem('auth', JSON.stringify({
user: response.user,
token: response.token,
}));Step 3: Auth Store Populated
The Pinia auth store holds the active session state in memory:
// From auth.store.ts
interface AuthState {
user: AuthUser | null;
token: string | null;
resetCodeVerified?: boolean;
branchServices: { id: number; name: string; code: string }[] | null;
}
export const useAuthStore = defineStore('auth', () => {
const state = reactive<AuthState>({
user: null,
token: null,
resetCodeVerified: false,
branchServices: null,
});
const isAuthenticated = computed(() => !!state.token);
// ... hydrate, reset, updateBranch
});Auth State Hydration
When the page refreshes, the Pinia store is empty. The hydrate() method restores state from sessionStorage:
// From auth.store.ts
const hydrate = () => {
const storedAuth = sessionStorage.getItem('auth');
if (storedAuth) {
try {
const storedStated: AuthState = JSON.parse(storedAuth);
state.user = storedStated.user;
state.token = storedStated.token;
state.resetCodeVerified = storedStated.resetCodeVerified;
state.branchServices = storedStated.user?.branch_services || [];
authBranchStore.selectedBranch = state.user?.default_branch || null;
} catch (e) {
state.user = null;
state.token = null;
state.resetCodeVerified = false;
console.warn('Failed to parse auth from sessionStorage:', e);
}
}
};hydrate() is called in two places:
- Auth middleware -- On every route navigation.
useApiFetch-- Before making API requests if the token is not in memory.
Auth Middleware
The global auth middleware (app/middleware/auth.ts) runs on every route navigation to enforce authentication:
// From middleware/auth.ts
const isTokenExpired = (token: string): boolean => {
try {
const parts = token.split('.');
// Only check expiry for JWT tokens (3 parts with base64 payload)
if (parts.length !== 3) return false;
const payload = JSON.parse(atob(parts[1] as string));
if (!payload.exp) return false;
return Date.now() >= payload.exp * 1000;
} catch {
// Non-JWT tokens (e.g., Sanctum) -- assume valid, let server handle expiry
return false;
}
};
export default defineNuxtRouteMiddleware((to) => {
const authStore = useAuthStore();
const publicPages = ['/login'];
const publicPrefixes = ['/reset-password'];
authStore.hydrate();
const isPublic =
publicPages.includes(to.path) ||
publicPrefixes.some((prefix) => to.path.startsWith(prefix));
if (!isPublic) {
if (!authStore.state.token || isTokenExpired(authStore.state.token)) {
authStore.reset();
sessionStorage.removeItem('auth');
return navigateTo('/login');
}
}
});How the Middleware Works
- Hydrates auth state from
sessionStorageon every navigation. - Checks if the route is public --
/loginand all/reset-password/*routes are accessible without authentication. - For protected routes, checks whether a token exists and whether it has expired.
- JWT expiry check -- Decodes the JWT payload and compares the
expclaim with the current time. If the token is not a JWT (Sanctum plain token), it skips client-side expiry and lets the server handle it. - If expired or missing -- Resets the auth store, clears
sessionStorage, and redirects to/login.
Public Route Configuration
const publicPages = ['/login'];
const publicPrefixes = ['/reset-password'];To add a new public route, add it to publicPages (exact match) or publicPrefixes (prefix match).
API Request Authentication
Every API request made through useApiFetch automatically includes the Bearer token:
// From useApiFetch.ts
export const useApiFetch = async <T>(
endpoint: string,
options: ApiFetchOptions = {}
): Promise<T> => {
const authStore = useAuthStore();
if (!authStore.state.token) {
authStore.hydrate();
}
const headers: Record<string, string> = {
Accept: 'application/json',
...(options.headers as Record<string, string>),
};
if (authStore.state.token) {
headers['Authorization'] = `Bearer ${authStore.state.token}`;
}
return $fetch<T>(url, { ...options, headers });
};Logout Flow
Logout performs three actions:
- API call -- Notifies the server to invalidate the token (
POST /auth/logout). - Store reset -- Clears the Pinia auth store.
- sessionStorage clear -- Removes persisted auth data.
// From auth.sevice.ts
async logout(): Promise<{ message: string }> {
const authStore = useAuthStore();
if (!authStore.state.token) {
authStore.hydrate();
}
const res = await $fetch(url, {
method: 'POST',
headers: {
Authorization: `Bearer ${authStore.state.token}`,
},
});
return res.data;
}After the API call, the login page typically runs:
authStore.reset();
sessionStorage.removeItem('auth');
navigateTo('/login');Password Reset Flow
The password reset flow is a multi-step process protected by the reset-password middleware.
Step 1: Send Reset Code
The user enters their email on /reset-password. The useSendCode composable sends the request:
// From useSendCode.ts
const sendPasswordResetCode = async (email: string) => {
if (!email) {
error.value = 'Please enter your email address';
return;
}
loading.value = true;
try {
await authService.sendResetCode(email);
success.value = true;
} catch (err: unknown) {
error.value = errorObj.message || 'Failed to send code';
} finally {
loading.value = false;
}
};Step 2: Verify Code
On /reset-password/enter-code, the user enters the code received via email:
// From useCodeVerification.ts
const verifyCode = async (email: string, code: string) => {
loading.value = true;
try {
await authService.verifyCode(email, code);
success.value = true;
} catch (err: unknown) {
error.value = errorObj.message || 'Failed to verify code';
} finally {
loading.value = false;
}
};Step 3: Set New Password
On /reset-password/new-password, the user enters a new password and confirmation:
// API call
await authService.resetPassword(email, code, password, password_confirmation);Step 4: Success
On /reset-password/success-state, a confirmation message is displayed.
Reset Password Middleware
The reset-password middleware enforces sequential step progression:
// From middleware/reset-password.ts
export default defineNuxtRouteMiddleware((to) => {
const store = useResetPasswordCodeStore();
const stepRoutes: Record<string, number> = {
'/reset-password': 1,
'/reset-password/enter-code': 2,
'/reset-password/new-password': 3,
'/reset-password/success-state': 4,
};
const requiredStep = stepRoutes[to.path];
if (!requiredStep) return;
// Step 1 is always accessible
if (requiredStep === 1) return;
// Redirect to current step if user tries to skip ahead
if (store.step < requiredStep) {
return navigateTo(routeForStep[store.step]);
}
});The useResetPasswordCodeStore tracks progress through the flow:
// From resetPassword.store.ts
export const useResetPasswordCodeStore = defineStore('resetPasswordCode', () => {
const step = ref(1);
const form = reactive<Form>({
email: '',
code: '',
newPassword: '',
confirmPassword: '',
});
const maskedEmail = computed(() => {
const email = form.email;
if (!email) return '';
const [local, domain] = email.split('@');
return `${local?.charAt(0)}***@${domain}`;
});
const resetStore = () => {
step.value = 1;
form.email = '';
form.code = '';
form.newPassword = '';
form.confirmPassword = '';
};
return { step, form, maskedEmail, resetStore };
});Branch Switching
CPR supports multi-branch operations. Users can switch between clinic branches without re-authenticating:
// From useBranchSwitcher.ts
const switchBranch = async (branchId: string) => {
loading.value = true;
try {
const branch = await authService.switchBranch(branchId);
branchStore.selectedBranch = branch;
success.value = true;
} catch (err) {
error.value = message;
} finally {
loading.value = false;
}
};The selected branch is stored in the useUserBranchStore and also updated in sessionStorage via authStore.updateBranch():
// From auth.store.ts
const updateBranch = (branch: Branch) => {
const storedAuth = sessionStorage.getItem('auth');
if (storedAuth) {
const storedStated: AuthState = JSON.parse(storedAuth);
if (storedStated.user?.default_branch) {
storedStated.user.default_branch = branch;
sessionStorage.setItem('auth', JSON.stringify(storedStated));
}
}
};Security Considerations
- JWT expiry is checked client-side but the server is the authority. Even if a token passes the client-side check, the server may reject it.
- Non-JWT tokens (Sanctum) skip client-side expiry checks. The catch block in
isTokenExpiredreturnsfalse, deferring to server-side validation. - Password reset flow cannot be skipped -- The middleware enforces sequential steps. A user cannot navigate directly to
/reset-password/new-passwordwithout completing prior steps. - Sentry captures auth errors -- Failed logins and API errors are reported to Sentry for monitoring.
