From be605c078903f6a2f93089a5fde5e98371eee10d Mon Sep 17 00:00:00 2001 From: Mapelujo Abdulkareem Date: Sat, 24 Jan 2026 19:12:16 +0100 Subject: [PATCH 1/2] feat(auth): implement authentication store with cookie synchronization --- frontend/package-lock.json | 18 +++ frontend/package.json | 2 + frontend/src/store/authStore.ts | 226 +++++++++++++++++++++++++++++++ frontend/src/types/user.types.ts | 64 +++++++++ 4 files changed, 310 insertions(+) create mode 100644 frontend/src/store/authStore.ts create mode 100644 frontend/src/types/user.types.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 9874640..69f50ab 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@tanstack/react-query-devtools": "^5.90.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.544.0", "next": "^15.5.9", "next-themes": "^0.4.6", @@ -30,6 +31,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", @@ -1465,6 +1467,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/js-cookie": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/@types/js-cookie/-/js-cookie-3.0.6.tgz", + "integrity": "sha512-wkw9yd1kEXOPnvEeEV1Go1MmxtBJL0RR79aOTAApecWFVu7w0NNXNqhcWgvw2YgZDYadliXkl14pa3WXw5jlCQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -4285,6 +4294,15 @@ "jiti": "lib/jiti-cli.mjs" } }, + "node_modules/js-cookie": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/js-cookie/-/js-cookie-3.0.5.tgz", + "integrity": "sha512-cEiJEAEoIbWfCZYKWhVwFuvPX1gETRYPw6LlaTKoxD3s2AkXzkCjnp6h0V77ozyqj0jakteJ4YqDJT830+lVGw==", + "license": "MIT", + "engines": { + "node": ">=14" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 12d108f..ff721e9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query-devtools": "^5.90.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "js-cookie": "^3.0.5", "lucide-react": "^0.544.0", "next": "^15.5.9", "next-themes": "^0.4.6", @@ -31,6 +32,7 @@ "devDependencies": { "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4", + "@types/js-cookie": "^3.0.6", "@types/node": "^20", "@types/react": "^19", "@types/react-dom": "^19", diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..9063954 --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,226 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import Cookies from 'js-cookie'; +import type { AuthState, User, SetAuthPayload } from '../types/user.types'; + +/** + * Authentication Store Actions + */ +interface AuthActions { + setAuth: (data: SetAuthPayload) => void; + updateUser: (userData: Partial) => void; + clearAuth: () => void; + setHasHydrated: (state: boolean) => void; +} + +type AuthStore = AuthState & AuthActions; + +/** + * Initial authentication state + */ +const initialState: AuthState = { + accessToken: null, + refreshToken: null, + user: null, + isAuthenticated: false, + tokenExpiresAt: null, + _hasHydrated: false, +}; + +/** + * Cookie configuration constants + */ +const COOKIE_OPTIONS = { + expires: 7, // 7 days + sameSite: 'strict' as const, + secure: true, + path: '/', +}; + +const ACCESS_TOKEN_COOKIE = 'accessToken'; +const USER_DATA_COOKIE = 'authUser'; + +/** + * Sync authentication data to cookies + * Called when auth state is updated + */ +const syncToCookies = (accessToken: string, user: User): void => { + // Sync access token to cookie + Cookies.set(ACCESS_TOKEN_COOKIE, accessToken, COOKIE_OPTIONS); + + // Sync user data to cookie (stringified JSON) + Cookies.set(USER_DATA_COOKIE, JSON.stringify(user), COOKIE_OPTIONS); +}; + +/** + * Remove authentication cookies + * Called during logout/clearAuth + */ +const removeAuthCookies = (): void => { + Cookies.remove(ACCESS_TOKEN_COOKIE, { path: '/' }); + Cookies.remove(USER_DATA_COOKIE, { path: '/' }); +}; + +/** + * Global Authentication Store + * + * Features: + * - Persistent storage using localStorage + * - Cookie synchronization for SSR/middleware + * - Hydration tracking to prevent Next.js mismatches + * - Optimized selectors for minimal re-renders + */ +export const useAuthStore = create()( + persist( + (set, get) => ({ + ...initialState, + + /** + * Set authentication state + * Automatically calculates tokenExpiresAt and syncs to cookies + * + * @param data - Auth payload with tokens, user, and optional expiresIn + */ + setAuth: (data: SetAuthPayload) => { + const { accessToken, refreshToken, user, expiresIn } = data; + + // Calculate token expiration timestamp + // If expiresIn provided (in seconds), convert to timestamp + // Otherwise set to 7 days from now (matching cookie expiry) + const tokenExpiresAt = expiresIn + ? Date.now() + expiresIn * 1000 + : Date.now() + 7 * 24 * 60 * 60 * 1000; + + // Sync tokens and user data to cookies for SSR/middleware access + syncToCookies(accessToken, user); + + // Update Zustand state + set({ + accessToken, + refreshToken, + user, + isAuthenticated: true, + tokenExpiresAt, + }); + }, + + /** + * Update user data only + * Merges partial user updates and re-syncs cookies + * + * @param userData - Partial user object to merge + */ + updateUser: (userData: Partial) => { + const currentUser = get().user; + const currentToken = get().accessToken; + + if (!currentUser) { + console.warn('Cannot update user: no user is authenticated'); + return; + } + + // Merge user updates + const updatedUser = { ...currentUser, ...userData }; + + // Re-sync updated user to cookies if token exists + if (currentToken) { + syncToCookies(currentToken, updatedUser); + } + + // Update Zustand state + set({ user: updatedUser }); + }, + + /** + * Clear authentication state + * Removes all cookies and resets state to initial values + */ + clearAuth: () => { + // Remove auth cookies + removeAuthCookies(); + + // Reset Zustand state (preserve _hasHydrated) + set({ + ...initialState, + _hasHydrated: get()._hasHydrated, + }); + }, + + /** + * Set hydration state + * Internal use for tracking when store has rehydrated + */ + setHasHydrated: (state: boolean) => { + set({ _hasHydrated: state }); + }, + }), + { + name: 'auth-storage', // localStorage key + storage: createJSONStorage(() => localStorage), + + // Only persist essential auth data (exclude _hasHydrated) + partialize: (state) => ({ + accessToken: state.accessToken, + refreshToken: state.refreshToken, + user: state.user, + isAuthenticated: state.isAuthenticated, + tokenExpiresAt: state.tokenExpiresAt, + }), + + // Handle rehydration to prevent Next.js hydration mismatches + onRehydrateStorage: () => { + return (state, error) => { + if (error) { + console.error('Auth store hydration error:', error); + } else { + console.log('Auth store hydrated successfully'); + // Mark hydration as complete + state?.setHasHydrated(true); + } + }; + }, + } + ) +); + +/** + * Optimized Selectors + * Prevent unnecessary re-renders by selecting specific slices + */ + +export const useIsAuthenticated = () => + useAuthStore((state) => state.isAuthenticated); + +export const useUser = () => useAuthStore((state) => state.user); + +export const useToken = () => useAuthStore((state) => state.accessToken); + +export const useRefreshToken = () => + useAuthStore((state) => state.refreshToken); + +export const useTokenExpiresAt = () => + useAuthStore((state) => state.tokenExpiresAt); + +/** + * Hydration Guard Hook + * Use this to prevent rendering auth-dependent content before hydration + * + * @example + * ```tsx + * const hasHydrated = useHasHydrated(); + * if (!hasHydrated) return ; + * ``` + */ +export const useHasHydrated = () => + useAuthStore((state) => state._hasHydrated); + +/** + * Auth Actions Hook + * Returns only the action methods (no state) + */ +export const useAuthActions = () => + useAuthStore((state) => ({ + setAuth: state.setAuth, + updateUser: state.updateUser, + clearAuth: state.clearAuth, + })); diff --git a/frontend/src/types/user.types.ts b/frontend/src/types/user.types.ts new file mode 100644 index 0000000..fecfe8a --- /dev/null +++ b/frontend/src/types/user.types.ts @@ -0,0 +1,64 @@ +/** + * User Types and Authentication Interfaces + * Defines the core User model and auth-related state interfaces + */ + +export enum UserRole { + USER = 'user', + ADMIN = 'admin', +} + +/** + * Core User interface + * Represents authenticated user data + */ +export interface User { + id: string; + email: string; + firstname: string; + lastname: string; + username?: string | null; + role: UserRole; + isActive: boolean; + isSuspended?: boolean; + isDeleted?: boolean; + avatar?: string | null; + createdAt: string | Date; + updatedAt: string | Date; + deletedAt?: string | Date | null; +} + +/** + * Authentication State + * Tracks all auth-related state in Zustand store + */ +export interface AuthState { + accessToken: string | null; + refreshToken: string | null; + user: User | null; + isAuthenticated: boolean; + tokenExpiresAt: number | null; + _hasHydrated: boolean; +} + +/** + * Authentication Response from API + * Expected shape from login/register endpoints + */ +export interface AuthResponse { + user: User; + accessToken: string; + refreshToken: string; + expiresIn?: number; // Token TTL in seconds (optional) +} + +/** + * SetAuth Action Payload + * Data structure for updating auth state + */ +export interface SetAuthPayload { + accessToken: string; + refreshToken: string; + user: User; + expiresIn?: number; // If provided, calculates tokenExpiresAt automatically +} From ba4209d75de8b9cbfb59485081cf9ac913df5993 Mon Sep 17 00:00:00 2001 From: Mapelujo Abdulkareem Date: Sat, 24 Jan 2026 19:20:49 +0100 Subject: [PATCH 2/2] refactor(auth): standardize string quotes in auth store and user types --- frontend/src/store/authStore.ts | 98 +++++++------------------------- frontend/src/types/user.types.ts | 20 +------ 2 files changed, 21 insertions(+), 97 deletions(-) diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts index 9063954..0dfc3db 100644 --- a/frontend/src/store/authStore.ts +++ b/frontend/src/store/authStore.ts @@ -1,11 +1,8 @@ -import { create } from 'zustand'; -import { persist, createJSONStorage } from 'zustand/middleware'; -import Cookies from 'js-cookie'; -import type { AuthState, User, SetAuthPayload } from '../types/user.types'; +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import Cookies from "js-cookie"; +import type { AuthState, User, SetAuthPayload } from "../types/user.types"; -/** - * Authentication Store Actions - */ interface AuthActions { setAuth: (data: SetAuthPayload) => void; updateUser: (userData: Partial) => void; @@ -15,9 +12,6 @@ interface AuthActions { type AuthStore = AuthState & AuthActions; -/** - * Initial authentication state - */ const initialState: AuthState = { accessToken: null, refreshToken: null, @@ -27,66 +21,37 @@ const initialState: AuthState = { _hasHydrated: false, }; -/** - * Cookie configuration constants - */ const COOKIE_OPTIONS = { expires: 7, // 7 days - sameSite: 'strict' as const, + sameSite: "strict" as const, secure: true, - path: '/', + path: "/", }; -const ACCESS_TOKEN_COOKIE = 'accessToken'; -const USER_DATA_COOKIE = 'authUser'; +const ACCESS_TOKEN_COOKIE = "accessToken"; +const USER_DATA_COOKIE = "authUser"; -/** - * Sync authentication data to cookies - * Called when auth state is updated - */ const syncToCookies = (accessToken: string, user: User): void => { // Sync access token to cookie Cookies.set(ACCESS_TOKEN_COOKIE, accessToken, COOKIE_OPTIONS); - + // Sync user data to cookie (stringified JSON) Cookies.set(USER_DATA_COOKIE, JSON.stringify(user), COOKIE_OPTIONS); }; -/** - * Remove authentication cookies - * Called during logout/clearAuth - */ const removeAuthCookies = (): void => { - Cookies.remove(ACCESS_TOKEN_COOKIE, { path: '/' }); - Cookies.remove(USER_DATA_COOKIE, { path: '/' }); + Cookies.remove(ACCESS_TOKEN_COOKIE, { path: "/" }); + Cookies.remove(USER_DATA_COOKIE, { path: "/" }); }; -/** - * Global Authentication Store - * - * Features: - * - Persistent storage using localStorage - * - Cookie synchronization for SSR/middleware - * - Hydration tracking to prevent Next.js mismatches - * - Optimized selectors for minimal re-renders - */ export const useAuthStore = create()( persist( (set, get) => ({ ...initialState, - /** - * Set authentication state - * Automatically calculates tokenExpiresAt and syncs to cookies - * - * @param data - Auth payload with tokens, user, and optional expiresIn - */ setAuth: (data: SetAuthPayload) => { const { accessToken, refreshToken, user, expiresIn } = data; - // Calculate token expiration timestamp - // If expiresIn provided (in seconds), convert to timestamp - // Otherwise set to 7 days from now (matching cookie expiry) const tokenExpiresAt = expiresIn ? Date.now() + expiresIn * 1000 : Date.now() + 7 * 24 * 60 * 60 * 1000; @@ -104,18 +69,12 @@ export const useAuthStore = create()( }); }, - /** - * Update user data only - * Merges partial user updates and re-syncs cookies - * - * @param userData - Partial user object to merge - */ updateUser: (userData: Partial) => { const currentUser = get().user; const currentToken = get().accessToken; if (!currentUser) { - console.warn('Cannot update user: no user is authenticated'); + console.warn("Cannot update user: no user is authenticated"); return; } @@ -131,10 +90,6 @@ export const useAuthStore = create()( set({ user: updatedUser }); }, - /** - * Clear authentication state - * Removes all cookies and resets state to initial values - */ clearAuth: () => { // Remove auth cookies removeAuthCookies(); @@ -146,18 +101,14 @@ export const useAuthStore = create()( }); }, - /** - * Set hydration state - * Internal use for tracking when store has rehydrated - */ setHasHydrated: (state: boolean) => { set({ _hasHydrated: state }); }, }), { - name: 'auth-storage', // localStorage key + name: "auth-storage", // localStorage key storage: createJSONStorage(() => localStorage), - + // Only persist essential auth data (exclude _hasHydrated) partialize: (state) => ({ accessToken: state.accessToken, @@ -171,16 +122,16 @@ export const useAuthStore = create()( onRehydrateStorage: () => { return (state, error) => { if (error) { - console.error('Auth store hydration error:', error); + console.error("Auth store hydration error:", error); } else { - console.log('Auth store hydrated successfully'); + console.log("Auth store hydrated successfully"); // Mark hydration as complete state?.setHasHydrated(true); } }; }, - } - ) + }, + ), ); /** @@ -201,18 +152,7 @@ export const useRefreshToken = () => export const useTokenExpiresAt = () => useAuthStore((state) => state.tokenExpiresAt); -/** - * Hydration Guard Hook - * Use this to prevent rendering auth-dependent content before hydration - * - * @example - * ```tsx - * const hasHydrated = useHasHydrated(); - * if (!hasHydrated) return ; - * ``` - */ -export const useHasHydrated = () => - useAuthStore((state) => state._hasHydrated); +export const useHasHydrated = () => useAuthStore((state) => state._hasHydrated); /** * Auth Actions Hook diff --git a/frontend/src/types/user.types.ts b/frontend/src/types/user.types.ts index fecfe8a..04724a3 100644 --- a/frontend/src/types/user.types.ts +++ b/frontend/src/types/user.types.ts @@ -4,14 +4,10 @@ */ export enum UserRole { - USER = 'user', - ADMIN = 'admin', + USER = "user", + ADMIN = "admin", } -/** - * Core User interface - * Represents authenticated user data - */ export interface User { id: string; email: string; @@ -28,10 +24,6 @@ export interface User { deletedAt?: string | Date | null; } -/** - * Authentication State - * Tracks all auth-related state in Zustand store - */ export interface AuthState { accessToken: string | null; refreshToken: string | null; @@ -41,10 +33,6 @@ export interface AuthState { _hasHydrated: boolean; } -/** - * Authentication Response from API - * Expected shape from login/register endpoints - */ export interface AuthResponse { user: User; accessToken: string; @@ -52,10 +40,6 @@ export interface AuthResponse { expiresIn?: number; // Token TTL in seconds (optional) } -/** - * SetAuth Action Payload - * Data structure for updating auth state - */ export interface SetAuthPayload { accessToken: string; refreshToken: string;