WellNuo/web/stores/authStore.ts
Sergei 3f0fe56e02 Add protected route middleware and auth store for web app
- 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)
2026-01-31 17:49:21 -08:00

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);