Add authentication context and provider for user management
Introduce `AuthContext` with login, logout, and token management. Includes protected route handling, token refresh logic, and user session persistence via React Context API. Prepares the app for authentication workflows.
This commit is contained in:
301
frontend/src/context/auth-context.tsx
Normal file
301
frontend/src/context/auth-context.tsx
Normal file
@@ -0,0 +1,301 @@
|
||||
'use client';
|
||||
|
||||
import React, {createContext, useContext, useState, useEffect, ReactNode, useCallback, useMemo} from 'react';
|
||||
import {useRouter, usePathname} from 'next/navigation';
|
||||
import {jwtDecode} from 'jwt-decode';
|
||||
import {client} from '@/client/client.gen';
|
||||
import {useMutation, useQueryClient, useQuery, type UseQueryOptions} from '@tanstack/react-query';
|
||||
import {loginMutation, getCurrentUserInfoOptions} from '@/client/@tanstack/react-query.gen';
|
||||
import {LoginRequest, UserResponse, Token} from '@/client/types.gen';
|
||||
|
||||
// JWT token payload interface
|
||||
interface TokenPayload {
|
||||
sub: string;
|
||||
exp: number;
|
||||
role?: string;
|
||||
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
// Auth context state interface
|
||||
interface AuthContextState {
|
||||
user: UserResponse | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
error: Error | null;
|
||||
login: (credentials: LoginRequest) => Promise<void>;
|
||||
logout: () => void;
|
||||
refreshToken: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Default context state
|
||||
const defaultAuthState: AuthContextState = {
|
||||
user: null,
|
||||
isAuthenticated: false,
|
||||
isLoading: true,
|
||||
error: null,
|
||||
login: async () => {
|
||||
},
|
||||
logout: () => {
|
||||
},
|
||||
refreshToken: async () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Create context
|
||||
const AuthContext = createContext<AuthContextState>(defaultAuthState);
|
||||
|
||||
// Custom hook to use the auth context
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext);
|
||||
if (!context) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Token management
|
||||
const TOKEN_KEY = 'accessToken';
|
||||
const REFRESH_TOKEN_KEY = 'refreshToken';
|
||||
|
||||
// Get token from storage
|
||||
const getStoredToken = (): string | null => {
|
||||
if (typeof window === 'undefined') return null;
|
||||
return localStorage.getItem(TOKEN_KEY);
|
||||
};
|
||||
|
||||
// Store token in storage
|
||||
const storeToken = (token: string | null, refreshToken: string | null = null): void => {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
if (token) {
|
||||
localStorage.setItem(TOKEN_KEY, token);
|
||||
if (refreshToken) {
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, refreshToken);
|
||||
}
|
||||
} else {
|
||||
localStorage.removeItem(TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
}
|
||||
};
|
||||
|
||||
// Check if token is expired
|
||||
const isTokenExpired = (token: string): boolean => {
|
||||
try {
|
||||
const decoded = jwtDecode<TokenPayload>(token);
|
||||
const currentTime = Date.now() / 1000;
|
||||
// Add a buffer of 10 seconds to prevent edge cases
|
||||
return decoded.exp < currentTime - 10;
|
||||
} catch (error) {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
// Auth Provider Props
|
||||
interface AuthProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
// Auth Provider Component
|
||||
export const AuthProvider: React.FC<AuthProviderProps> = ({children}: AuthProviderProps) => {
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Set up login mutation
|
||||
const loginMutationHook = useMutation(loginMutation());
|
||||
|
||||
// Configure API client with token if available
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
if (token) {
|
||||
client.setConfig({
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
|
||||
const hasValidToken = useCallback((): boolean => {
|
||||
const token = getStoredToken();
|
||||
return Boolean(token && !isTokenExpired(token));
|
||||
}, []);
|
||||
|
||||
// Create query options
|
||||
const userQueryOptions = {
|
||||
...getCurrentUserInfoOptions(),
|
||||
enabled: hasValidToken(),
|
||||
retry: false,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
} as UseQueryOptions;
|
||||
|
||||
// Use the query without onError in the options
|
||||
const {data: user, isLoading, error, refetch} = useQuery(userQueryOptions);
|
||||
|
||||
// Handle error with a separate effect
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
// Clear tokens on error (unauthorized)
|
||||
storeToken(null);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Login function
|
||||
const login = useCallback(async (credentials: LoginRequest): Promise<void> => {
|
||||
try {
|
||||
const response = await loginMutationHook.mutateAsync({
|
||||
body: credentials
|
||||
});
|
||||
|
||||
// Store tokens
|
||||
storeToken(response.access_token, response.refresh_token || null);
|
||||
|
||||
// Configure client with new token
|
||||
client.setConfig({
|
||||
headers: {
|
||||
Authorization: `Bearer ${response.access_token}`
|
||||
}
|
||||
});
|
||||
|
||||
// Trigger user data fetch
|
||||
await refetch();
|
||||
|
||||
// Redirect after login
|
||||
if (pathname === '/login') {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Login failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}, [loginMutationHook, refetch, router, pathname]);
|
||||
|
||||
// Logout function
|
||||
const logout = useCallback((): void => {
|
||||
// Clear tokens
|
||||
storeToken(null);
|
||||
|
||||
// Remove auth headers
|
||||
client.setConfig({
|
||||
headers: {
|
||||
Authorization: undefined
|
||||
}
|
||||
});
|
||||
|
||||
// Clear user from cache
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: getCurrentUserInfoOptions().queryKey
|
||||
});
|
||||
|
||||
// Redirect to login page
|
||||
router.push('/login');
|
||||
}, [router, queryClient]);
|
||||
|
||||
// Refresh token function
|
||||
const refreshToken = useCallback(async (): Promise<void> => {
|
||||
const refreshTokenValue = localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
|
||||
if (!refreshTokenValue) {
|
||||
logout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await client.post<Token>({
|
||||
url: '/api/v1/auth/refresh',
|
||||
body: {refresh_token: refreshTokenValue},
|
||||
});
|
||||
|
||||
const newTokens = response.data;
|
||||
|
||||
if (newTokens) {
|
||||
// Store new tokens
|
||||
storeToken(newTokens.access_token, newTokens.refresh_token || null);
|
||||
|
||||
// Update client headers
|
||||
client.setConfig({
|
||||
headers: {
|
||||
Authorization: `Bearer ${newTokens.access_token}`
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
// Refetch user data
|
||||
await refetch();
|
||||
} catch (error) {
|
||||
console.error('Token refresh failed:', error);
|
||||
logout();
|
||||
}
|
||||
}, [logout, refetch]);
|
||||
|
||||
// Check token validity and refresh if needed on route changes
|
||||
useEffect(() => {
|
||||
const token = getStoredToken();
|
||||
|
||||
if (token && isTokenExpired(token)) {
|
||||
refreshToken();
|
||||
}
|
||||
|
||||
setIsInitializing(false);
|
||||
}, [pathname, refreshToken]);
|
||||
|
||||
// Protected routes logic
|
||||
useEffect(() => {
|
||||
if (isInitializing) return;
|
||||
|
||||
const protectedRoutes = [
|
||||
'/admin',
|
||||
'/dashboard',
|
||||
'/events/create',
|
||||
'/events/edit',
|
||||
'/profile',
|
||||
];
|
||||
|
||||
const publicOnlyRoutes = [
|
||||
'/login',
|
||||
'/register',
|
||||
];
|
||||
|
||||
const publicRoutes = [
|
||||
'/',
|
||||
'/invite',
|
||||
'/rsvp',
|
||||
];
|
||||
|
||||
const isProtectedRoute = protectedRoutes.some(route => pathname?.startsWith(route));
|
||||
const isPublicOnlyRoute = publicOnlyRoutes.some(route => pathname === route);
|
||||
|
||||
// Handle loading state
|
||||
if (isLoading && isProtectedRoute) return;
|
||||
|
||||
// Redirect to login if not authenticated but trying to access protected route
|
||||
if (isProtectedRoute && !user) {
|
||||
router.push(`/login?redirect=${encodeURIComponent(pathname || '')}`);
|
||||
}
|
||||
|
||||
// Redirect to dashboard if authenticated but trying to access public-only route
|
||||
if (isPublicOnlyRoute && user) {
|
||||
router.push('/dashboard');
|
||||
}
|
||||
}, [user, isLoading, pathname, router, isInitializing]);
|
||||
|
||||
// Create the context value
|
||||
const value = useMemo<AuthContextState>(() => ({
|
||||
user: user as UserResponse | null,
|
||||
isAuthenticated: !!user,
|
||||
isLoading: isInitializing || isLoading,
|
||||
error: error as Error | null,
|
||||
login,
|
||||
logout,
|
||||
refreshToken,
|
||||
}), [user, isInitializing, isLoading, error, login, logout, refreshToken]);
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={value}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user