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:
2025-03-05 10:09:19 +01:00
parent 761254f940
commit a7504f6876

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