- Implement Next.js middleware for route protection - Create Zustand auth store for web (similar to mobile) - Add comprehensive tests for middleware and auth store - Protect authenticated routes (/dashboard, /profile) - Redirect unauthenticated users to /login - Redirect authenticated users from auth routes to /dashboard - Handle session expiration with 401 callback - Set access token cookie for middleware - All tests passing (105 tests total)
368 lines
8.5 KiB
TypeScript
368 lines
8.5 KiB
TypeScript
import { create } from 'zustand';
|
|
import api, { setOnUnauthorizedCallback } from '@/lib/api';
|
|
|
|
/**
|
|
* User type matching the API response
|
|
*/
|
|
interface User {
|
|
user_id: number;
|
|
email?: string;
|
|
firstName?: string | null;
|
|
lastName?: string | null;
|
|
phone?: string | null;
|
|
max_role?: string | number;
|
|
privileges?: string;
|
|
}
|
|
|
|
/**
|
|
* Auth Store State
|
|
* Manages authentication state using Zustand
|
|
*/
|
|
interface AuthState {
|
|
user: User | null;
|
|
isLoading: boolean;
|
|
isInitializing: boolean;
|
|
isAuthenticated: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
/**
|
|
* Auth Store Actions
|
|
* All auth-related operations
|
|
*/
|
|
interface AuthActions {
|
|
// Authentication flow
|
|
checkEmail: (email: string) => Promise<{ exists: boolean; name?: string | null }>;
|
|
requestOtp: (email: string) => Promise<{ success: boolean }>;
|
|
verifyOtp: (email: string, code: string) => Promise<boolean>;
|
|
logout: () => Promise<void>;
|
|
|
|
// State management
|
|
clearError: () => void;
|
|
refreshAuth: () => Promise<void>;
|
|
updateUser: (updates: Partial<User>) => void;
|
|
|
|
// Internal helpers
|
|
_checkAuth: () => Promise<void>;
|
|
_setUnauthorizedCallback: () => void;
|
|
}
|
|
|
|
/**
|
|
* Combined Auth Store Type
|
|
*/
|
|
type AuthStore = AuthState & AuthActions;
|
|
|
|
/**
|
|
* Initial state for auth store
|
|
*/
|
|
const initialState: AuthState = {
|
|
user: null,
|
|
isLoading: false,
|
|
isInitializing: true,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
};
|
|
|
|
/**
|
|
* Zustand Auth Store for Web
|
|
*
|
|
* Manages authentication state with localStorage for web browsers.
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* import { useAuthStore } from '@/stores/authStore';
|
|
*
|
|
* function MyComponent() {
|
|
* const { user, isAuthenticated, logout } = useAuthStore();
|
|
*
|
|
* return (
|
|
* <div>
|
|
* {isAuthenticated ? (
|
|
* <p>Welcome {user?.firstName}</p>
|
|
* ) : (
|
|
* <p>Please login</p>
|
|
* )}
|
|
* </div>
|
|
* );
|
|
* }
|
|
* ```
|
|
*/
|
|
export const useAuthStore = create<AuthStore>((set, get) => ({
|
|
...initialState,
|
|
|
|
/**
|
|
* Internal: Check authentication status on app start
|
|
* Called automatically when store initializes
|
|
*/
|
|
_checkAuth: async () => {
|
|
try {
|
|
const isAuth = await api.isAuthenticated();
|
|
|
|
if (isAuth) {
|
|
const user = await api.getStoredUser();
|
|
|
|
set({
|
|
user,
|
|
isLoading: false,
|
|
isInitializing: false,
|
|
isAuthenticated: !!user,
|
|
error: null,
|
|
});
|
|
|
|
// Set cookie for middleware
|
|
if (user) {
|
|
const token = await api.getToken();
|
|
if (token) {
|
|
document.cookie = `accessToken=${token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax`;
|
|
}
|
|
}
|
|
} else {
|
|
set({
|
|
user: null,
|
|
isLoading: false,
|
|
isInitializing: false,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
|
|
// Clear cookie
|
|
document.cookie = 'accessToken=; path=/; max-age=0';
|
|
}
|
|
} catch {
|
|
set({
|
|
user: null,
|
|
isLoading: false,
|
|
isInitializing: false,
|
|
isAuthenticated: false,
|
|
error: 'Failed to check authentication',
|
|
});
|
|
|
|
// Clear cookie on error
|
|
document.cookie = 'accessToken=; path=/; max-age=0';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Internal: Setup callback for 401 unauthorized responses
|
|
* Called automatically when store initializes
|
|
*/
|
|
_setUnauthorizedCallback: () => {
|
|
setOnUnauthorizedCallback(() => {
|
|
api.logout().then(() => {
|
|
set({
|
|
user: null,
|
|
isLoading: false,
|
|
isInitializing: false,
|
|
isAuthenticated: false,
|
|
error: 'Session expired. Please login again.',
|
|
});
|
|
|
|
// Clear cookie
|
|
document.cookie = 'accessToken=; path=/; max-age=0';
|
|
|
|
// Redirect to login
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = '/login';
|
|
}
|
|
});
|
|
});
|
|
},
|
|
|
|
/**
|
|
* Check if email exists in database
|
|
* Step 1 of OTP flow
|
|
*/
|
|
checkEmail: async (email: string) => {
|
|
set({ isLoading: true, error: null });
|
|
|
|
try {
|
|
const response = await api.checkEmail(email);
|
|
|
|
if (response.ok && response.data) {
|
|
set({ isLoading: false });
|
|
return { exists: response.data.exists, name: response.data.name };
|
|
}
|
|
|
|
// API failed - assume new user
|
|
set({ isLoading: false });
|
|
return { exists: false };
|
|
} catch {
|
|
set({
|
|
isLoading: false,
|
|
error: 'Network error. Please check your connection.',
|
|
});
|
|
return { exists: false };
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Request OTP code via email
|
|
* Step 2 of OTP flow
|
|
*/
|
|
requestOtp: async (email: string) => {
|
|
set({ isLoading: true, error: null });
|
|
|
|
try {
|
|
const response = await api.requestOTP(email);
|
|
|
|
if (response.ok) {
|
|
set({ isLoading: false });
|
|
return { success: true };
|
|
}
|
|
|
|
// API failed
|
|
set({
|
|
isLoading: false,
|
|
error: 'Failed to send verification code. Please try again.',
|
|
});
|
|
return { success: false };
|
|
} catch {
|
|
set({
|
|
isLoading: false,
|
|
error: 'Network error. Please check your connection.',
|
|
});
|
|
return { success: false };
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Verify OTP code and authenticate user
|
|
* Step 3 of OTP flow - completes authentication
|
|
*/
|
|
verifyOtp: async (email: string, code: string) => {
|
|
set({ isLoading: true, error: null });
|
|
|
|
try {
|
|
const verifyResponse = await api.verifyOTP(email, code);
|
|
|
|
if (verifyResponse.ok && verifyResponse.data) {
|
|
const user: User = {
|
|
user_id: parseInt(verifyResponse.data.user.id, 10),
|
|
email: email,
|
|
firstName: verifyResponse.data.user.first_name || null,
|
|
lastName: verifyResponse.data.user.last_name || null,
|
|
max_role: 'USER',
|
|
privileges: '',
|
|
};
|
|
|
|
set({
|
|
user,
|
|
isLoading: false,
|
|
isInitializing: false,
|
|
isAuthenticated: true,
|
|
error: null,
|
|
});
|
|
|
|
// Set cookie for middleware
|
|
document.cookie = `accessToken=${verifyResponse.data.token}; path=/; max-age=${60 * 60 * 24 * 7}; SameSite=Lax`;
|
|
|
|
return true;
|
|
}
|
|
|
|
// Wrong OTP code
|
|
set({
|
|
isLoading: false,
|
|
error: 'Invalid verification code. Please try again.',
|
|
});
|
|
return false;
|
|
} catch (error) {
|
|
set({
|
|
isLoading: false,
|
|
error: error instanceof Error ? error.message : 'Verification failed',
|
|
});
|
|
return false;
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Logout user and clear all auth state
|
|
* Calls api.logout() which clears localStorage tokens
|
|
*/
|
|
logout: async () => {
|
|
set({ isLoading: true });
|
|
|
|
try {
|
|
await api.logout();
|
|
} finally {
|
|
set({
|
|
user: null,
|
|
isLoading: false,
|
|
isInitializing: false,
|
|
isAuthenticated: false,
|
|
error: null,
|
|
});
|
|
|
|
// Clear cookie
|
|
document.cookie = 'accessToken=; path=/; max-age=0';
|
|
|
|
// Redirect to login
|
|
if (typeof window !== 'undefined') {
|
|
window.location.href = '/login';
|
|
}
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Clear error state
|
|
* Used to dismiss error messages
|
|
*/
|
|
clearError: () => {
|
|
set({ error: null });
|
|
},
|
|
|
|
/**
|
|
* Refresh authentication state from storage
|
|
* Re-checks tokens and user data
|
|
*/
|
|
refreshAuth: async () => {
|
|
await get()._checkAuth();
|
|
},
|
|
|
|
/**
|
|
* Update user profile in state
|
|
* Only updates local state - does NOT call API
|
|
* Use api.updateProfile() separately to persist changes
|
|
*/
|
|
updateUser: (updates: Partial<User>) => {
|
|
const currentUser = get().user;
|
|
if (!currentUser) return;
|
|
|
|
set({
|
|
user: { ...currentUser, ...updates },
|
|
});
|
|
},
|
|
}));
|
|
|
|
/**
|
|
* Initialize auth store
|
|
* Call this once at app startup (in layout.tsx or _app.tsx)
|
|
*
|
|
* @example
|
|
* ```tsx
|
|
* // In app/layout.tsx
|
|
* 'use client';
|
|
* import { useEffect } from 'react';
|
|
* import { initAuthStore } from '@/stores/authStore';
|
|
*
|
|
* export default function RootLayout() {
|
|
* useEffect(() => {
|
|
* initAuthStore();
|
|
* }, []);
|
|
* }
|
|
* ```
|
|
*/
|
|
export function initAuthStore() {
|
|
const store = useAuthStore.getState();
|
|
store._setUnauthorizedCallback();
|
|
store._checkAuth();
|
|
}
|
|
|
|
/**
|
|
* Selector hooks for optimized re-renders
|
|
* Use these instead of full store when you only need specific values
|
|
*/
|
|
export const useAuthUser = () => useAuthStore((state) => state.user);
|
|
export const useIsAuthenticated = () => useAuthStore((state) => state.isAuthenticated);
|
|
export const useAuthLoading = () => useAuthStore((state) => state.isLoading);
|
|
export const useAuthError = () => useAuthStore((state) => state.error);
|