diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 5d61d43..4a64a59 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,6 +16,7 @@ "@tanstack/react-query-devtools": "^5.91.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", @@ -1488,6 +1490,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", @@ -4258,6 +4267,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 999095b..af478c8 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,6 +17,7 @@ "@tanstack/react-query-devtools": "^5.91.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..0dfc3db --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,166 @@ +import { create } from "zustand"; +import { persist, createJSONStorage } from "zustand/middleware"; +import Cookies from "js-cookie"; +import type { AuthState, User, SetAuthPayload } from "../types/user.types"; + +interface AuthActions { + setAuth: (data: SetAuthPayload) => void; + updateUser: (userData: Partial) => void; + clearAuth: () => void; + setHasHydrated: (state: boolean) => void; +} + +type AuthStore = AuthState & AuthActions; + +const initialState: AuthState = { + accessToken: null, + refreshToken: null, + user: null, + isAuthenticated: false, + tokenExpiresAt: null, + _hasHydrated: false, +}; + +const COOKIE_OPTIONS = { + expires: 7, // 7 days + sameSite: "strict" as const, + secure: true, + path: "/", +}; + +const ACCESS_TOKEN_COOKIE = "accessToken"; +const USER_DATA_COOKIE = "authUser"; + +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); +}; + +const removeAuthCookies = (): void => { + Cookies.remove(ACCESS_TOKEN_COOKIE, { path: "/" }); + Cookies.remove(USER_DATA_COOKIE, { path: "/" }); +}; + +export const useAuthStore = create()( + persist( + (set, get) => ({ + ...initialState, + + setAuth: (data: SetAuthPayload) => { + const { accessToken, refreshToken, user, expiresIn } = data; + + 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, + }); + }, + + 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 }); + }, + + clearAuth: () => { + // Remove auth cookies + removeAuthCookies(); + + // Reset Zustand state (preserve _hasHydrated) + set({ + ...initialState, + _hasHydrated: get()._hasHydrated, + }); + }, + + 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); + +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..04724a3 --- /dev/null +++ b/frontend/src/types/user.types.ts @@ -0,0 +1,48 @@ +/** + * User Types and Authentication Interfaces + * Defines the core User model and auth-related state interfaces + */ + +export enum UserRole { + USER = "user", + ADMIN = "admin", +} + +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; +} + +export interface AuthState { + accessToken: string | null; + refreshToken: string | null; + user: User | null; + isAuthenticated: boolean; + tokenExpiresAt: number | null; + _hasHydrated: boolean; +} + +export interface AuthResponse { + user: User; + accessToken: string; + refreshToken: string; + expiresIn?: number; // Token TTL in seconds (optional) +} + +export interface SetAuthPayload { + accessToken: string; + refreshToken: string; + user: User; + expiresIn?: number; // If provided, calculates tokenExpiresAt automatically +}