mirror of
https://github.com/pacnpal/thrilltrack-explorer.git
synced 2025-12-27 05:26:58 -05:00
Refactor code structure and remove redundant changes
This commit is contained in:
163
lib/api/client.ts
Normal file
163
lib/api/client.ts
Normal file
@@ -0,0 +1,163 @@
|
||||
import axios, { AxiosError, AxiosInstance, AxiosRequestConfig, InternalAxiosRequestConfig } from 'axios';
|
||||
import { env } from '@/lib/env';
|
||||
|
||||
// Import tokenStorage dynamically to avoid circular dependencies
|
||||
const getTokenStorage = () => {
|
||||
if (typeof window !== 'undefined') {
|
||||
// Dynamic import to avoid SSR issues
|
||||
return require('@/lib/services/auth/tokenStorage').tokenStorage;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Track if we're currently refreshing to prevent multiple refresh attempts
|
||||
let isRefreshing = false;
|
||||
let refreshSubscribers: ((token: string) => void)[] = [];
|
||||
|
||||
function subscribeTokenRefresh(cb: (token: string) => void) {
|
||||
refreshSubscribers.push(cb);
|
||||
}
|
||||
|
||||
function onTokenRefreshed(token: string) {
|
||||
refreshSubscribers.forEach((cb) => cb(token));
|
||||
refreshSubscribers = [];
|
||||
}
|
||||
|
||||
// Create axios instance with default config
|
||||
const apiClient: AxiosInstance = axios.create({
|
||||
baseURL: env.NEXT_PUBLIC_DJANGO_API_URL,
|
||||
timeout: 30000,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor - Add auth token
|
||||
apiClient.interceptors.request.use(
|
||||
(config: InternalAxiosRequestConfig) => {
|
||||
// Add auth token if available (client-side only)
|
||||
if (typeof window !== 'undefined') {
|
||||
const tokenStorage = getTokenStorage();
|
||||
const token = tokenStorage?.getAccessToken();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Log requests in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[API Request] ${config.method?.toUpperCase()} ${config.url}`);
|
||||
}
|
||||
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor - Handle errors and token refresh
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => {
|
||||
// Log responses in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log(`[API Response] ${response.config.method?.toUpperCase()} ${response.config.url}`, response.status);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
async (error: AxiosError) => {
|
||||
const originalRequest = error.config as AxiosRequestConfig & { _retry?: boolean };
|
||||
|
||||
// Log errors in development
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.error('[API Error]', error.message, error.response?.status);
|
||||
}
|
||||
|
||||
// Handle 401 Unauthorized - attempt token refresh
|
||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||
if (typeof window !== 'undefined') {
|
||||
const tokenStorage = getTokenStorage();
|
||||
const refreshToken = tokenStorage?.getRefreshToken();
|
||||
|
||||
// Only attempt refresh if we have a refresh token
|
||||
if (refreshToken && !isRefreshing) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
if (!isRefreshing) {
|
||||
isRefreshing = true;
|
||||
|
||||
try {
|
||||
// Attempt to refresh the token
|
||||
const response = await axios.post(
|
||||
`${env.NEXT_PUBLIC_DJANGO_API_URL}/api/v1/auth/token/refresh`,
|
||||
{ refresh: refreshToken }
|
||||
);
|
||||
|
||||
const { access, refresh: newRefreshToken } = response.data;
|
||||
|
||||
// Store new tokens
|
||||
tokenStorage.setAccessToken(access);
|
||||
if (newRefreshToken) {
|
||||
tokenStorage.setRefreshToken(newRefreshToken);
|
||||
}
|
||||
|
||||
// Notify all subscribers
|
||||
onTokenRefreshed(access);
|
||||
isRefreshing = false;
|
||||
|
||||
// Retry the original request with new token
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${access}`;
|
||||
}
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed, clear tokens and redirect to login
|
||||
isRefreshing = false;
|
||||
tokenStorage?.clearTokens();
|
||||
|
||||
// Redirect to login page
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login?session_expired=true';
|
||||
}
|
||||
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
} else {
|
||||
// If already refreshing, wait for the refresh to complete
|
||||
return new Promise((resolve) => {
|
||||
subscribeTokenRefresh((token: string) => {
|
||||
if (originalRequest.headers) {
|
||||
originalRequest.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
resolve(apiClient(originalRequest));
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
// No refresh token available, clear storage and redirect
|
||||
tokenStorage?.clearTokens();
|
||||
if (window.location.pathname !== '/login') {
|
||||
window.location.href = '/login?session_expired=true';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Retry logic for network errors
|
||||
const config = error.config as AxiosRequestConfig & { _retryCount?: number };
|
||||
if (!error.response && config && !config._retryCount) {
|
||||
config._retryCount = (config._retryCount || 0) + 1;
|
||||
if (config._retryCount <= 3) {
|
||||
// Exponential backoff
|
||||
const delay = Math.min(1000 * Math.pow(2, config._retryCount - 1), 10000);
|
||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||
return apiClient(config);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export { apiClient };
|
||||
export type { AxiosError };
|
||||
93
lib/api/errorHandler.ts
Normal file
93
lib/api/errorHandler.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { AxiosError } from 'axios';
|
||||
|
||||
export interface ApiError {
|
||||
message: string;
|
||||
code?: string;
|
||||
status: number;
|
||||
details?: Record<string, string[]>;
|
||||
}
|
||||
|
||||
export function handleApiError(error: unknown): ApiError {
|
||||
if (error instanceof AxiosError) {
|
||||
const status = error.response?.status || 500;
|
||||
const data = error.response?.data;
|
||||
|
||||
// Django validation errors
|
||||
if (status === 400 && data) {
|
||||
return {
|
||||
message: 'Validation failed',
|
||||
code: 'VALIDATION_ERROR',
|
||||
status,
|
||||
details: data,
|
||||
};
|
||||
}
|
||||
|
||||
// Authentication errors
|
||||
if (status === 401) {
|
||||
return {
|
||||
message: 'Authentication required',
|
||||
code: 'UNAUTHORIZED',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
// Permission errors
|
||||
if (status === 403) {
|
||||
return {
|
||||
message: 'Permission denied',
|
||||
code: 'FORBIDDEN',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
// Not found
|
||||
if (status === 404) {
|
||||
return {
|
||||
message: 'Resource not found',
|
||||
code: 'NOT_FOUND',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
if (status === 429) {
|
||||
return {
|
||||
message: 'Too many requests',
|
||||
code: 'RATE_LIMITED',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
// Server errors
|
||||
if (status >= 500) {
|
||||
return {
|
||||
message: 'Server error occurred',
|
||||
code: 'SERVER_ERROR',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
message: data?.message || error.message || 'An error occurred',
|
||||
code: 'API_ERROR',
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
// Network errors
|
||||
return {
|
||||
message: 'Network error occurred',
|
||||
code: 'NETWORK_ERROR',
|
||||
status: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatErrorForUser(error: ApiError): string {
|
||||
if (error.details) {
|
||||
const messages = Object.entries(error.details)
|
||||
.map(([field, errors]) => `${field}: ${errors.join(', ')}`)
|
||||
.join('\n');
|
||||
return messages;
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
3
lib/api/index.ts
Normal file
3
lib/api/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { apiClient } from './client';
|
||||
export { handleApiError, formatErrorForUser } from './errorHandler';
|
||||
export type { ApiError } from './errorHandler';
|
||||
102
lib/cloudflare.ts
Normal file
102
lib/cloudflare.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* CloudFlare Images utility
|
||||
*
|
||||
* Provides helper functions to construct CloudFlare Images URLs
|
||||
* using the configured CDN domain (cdn.thrillwiki.com)
|
||||
*/
|
||||
|
||||
import { env } from './env';
|
||||
|
||||
/**
|
||||
* Available image variants for CloudFlare Images
|
||||
*/
|
||||
export type ImageVariant = 'public' | 'thumbnail' | 'banner' | 'avatar' | 'og';
|
||||
|
||||
/**
|
||||
* Get CloudFlare Images URL for a given image ID and variant
|
||||
*
|
||||
* @param imageId - CloudFlare image ID
|
||||
* @param variant - Image variant (default: 'public')
|
||||
* @returns Full CloudFlare CDN URL
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const url = getCloudFlareImageUrl('abc123', 'thumbnail');
|
||||
* // Returns: https://cdn.thrillwiki.com/images/abc123/thumbnail
|
||||
* ```
|
||||
*/
|
||||
export function getCloudFlareImageUrl(
|
||||
imageId: string,
|
||||
variant: ImageVariant = 'public'
|
||||
): string {
|
||||
const baseUrl = env.NEXT_PUBLIC_CLOUDFLARE_IMAGE_URL;
|
||||
|
||||
// cdn.thrillwiki.com format: {base_url}/images/{image-id}/{variant-id}
|
||||
return `${baseUrl}/images/${imageId}/${variant}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract image ID from a CloudFlare Images URL
|
||||
*
|
||||
* @param url - CloudFlare Images URL
|
||||
* @returns Image ID or null if not a valid CloudFlare URL
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const imageId = extractImageId('https://cdn.thrillwiki.com/images/abc123/public');
|
||||
* // Returns: 'abc123'
|
||||
* ```
|
||||
*/
|
||||
export function extractImageId(url: string): string | null {
|
||||
try {
|
||||
// Pattern for cdn.thrillwiki.com: /images/{id}/{variant}
|
||||
const cdnMatch = url.match(/\/images\/([^\/]+)\/[^\/]+$/);
|
||||
if (cdnMatch) {
|
||||
return cdnMatch[1];
|
||||
}
|
||||
|
||||
// Pattern for imagedelivery.net: /{hash}/{id}/{variant}
|
||||
const deliveryMatch = url.match(/imagedelivery\.net\/[^\/]+\/([^\/]+)\/[^\/]+$/);
|
||||
if (deliveryMatch) {
|
||||
return deliveryMatch[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a URL is a CloudFlare Images URL
|
||||
*
|
||||
* @param url - URL to check
|
||||
* @returns true if URL is a CloudFlare Images URL
|
||||
*/
|
||||
export function isCloudFlareImageUrl(url: string): boolean {
|
||||
return url.includes('cdn.thrillwiki.com/images') ||
|
||||
url.includes('imagedelivery.net');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get multiple variant URLs for an image
|
||||
*
|
||||
* @param imageId - CloudFlare image ID
|
||||
* @param variants - Array of variants to generate URLs for
|
||||
* @returns Object mapping variant names to URLs
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const urls = getImageVariants('abc123', ['public', 'thumbnail']);
|
||||
* // Returns: { public: '...', thumbnail: '...' }
|
||||
* ```
|
||||
*/
|
||||
export function getImageVariants(
|
||||
imageId: string,
|
||||
variants: ImageVariant[]
|
||||
): Record<ImageVariant, string> {
|
||||
return variants.reduce((acc, variant) => {
|
||||
acc[variant] = getCloudFlareImageUrl(imageId, variant);
|
||||
return acc;
|
||||
}, {} as Record<ImageVariant, string>);
|
||||
}
|
||||
316
lib/contexts/AuthContext.tsx
Normal file
316
lib/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
h'use client';
|
||||
|
||||
/**
|
||||
* Authentication Context Provider
|
||||
*
|
||||
* Provides authentication state and methods to the entire application.
|
||||
* Handles token refresh, session management, and user state.
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useEffect, useState, useCallback, useRef } from 'react';
|
||||
import {
|
||||
User,
|
||||
AuthContextType,
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
UpdateProfileData,
|
||||
ChangePasswordData,
|
||||
} from '@/lib/types/auth';
|
||||
import {
|
||||
authService,
|
||||
oauthService,
|
||||
mfaService,
|
||||
tokenStorage,
|
||||
getTimeUntilExpiry,
|
||||
isRefreshTokenExpired,
|
||||
OAuthProvider,
|
||||
} from '@/lib/services/auth';
|
||||
|
||||
// Create the context
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined);
|
||||
|
||||
// Token refresh interval (check every minute)
|
||||
const TOKEN_CHECK_INTERVAL = 60 * 1000;
|
||||
|
||||
// Refresh tokens 5 minutes before expiry
|
||||
const REFRESH_BUFFER = 5 * 60 * 1000;
|
||||
|
||||
interface AuthProviderProps {
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: AuthProviderProps) {
|
||||
const [user, setUser] = useState<User | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isInitialized, setIsInitialized] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const refreshIntervalRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const isRefreshingRef = useRef(false);
|
||||
|
||||
/**
|
||||
* Check if we need to refresh the access token
|
||||
*/
|
||||
const shouldRefreshToken = useCallback((): boolean => {
|
||||
const timeUntilExpiry = getTimeUntilExpiry();
|
||||
|
||||
if (timeUntilExpiry === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Refresh if token expires in less than 5 minutes
|
||||
return timeUntilExpiry < REFRESH_BUFFER;
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Refresh the access token
|
||||
*/
|
||||
const refreshToken = useCallback(async () => {
|
||||
// Prevent multiple simultaneous refresh attempts
|
||||
if (isRefreshingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if refresh token is still valid
|
||||
if (isRefreshTokenExpired()) {
|
||||
console.log('Refresh token expired, logging out');
|
||||
await logout();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isRefreshingRef.current = true;
|
||||
await authService.refreshAccessToken();
|
||||
console.log('Access token refreshed successfully');
|
||||
} catch (error) {
|
||||
console.error('Failed to refresh token:', error);
|
||||
// If refresh fails, log out the user
|
||||
await logout();
|
||||
} finally {
|
||||
isRefreshingRef.current = false;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Start the token refresh interval
|
||||
*/
|
||||
const startTokenRefreshInterval = useCallback(() => {
|
||||
// Clear existing interval
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
}
|
||||
|
||||
// Check token every minute
|
||||
refreshIntervalRef.current = setInterval(() => {
|
||||
if (shouldRefreshToken()) {
|
||||
refreshToken();
|
||||
}
|
||||
}, TOKEN_CHECK_INTERVAL);
|
||||
}, [shouldRefreshToken, refreshToken]);
|
||||
|
||||
/**
|
||||
* Stop the token refresh interval
|
||||
*/
|
||||
const stopTokenRefreshInterval = useCallback(() => {
|
||||
if (refreshIntervalRef.current) {
|
||||
clearInterval(refreshIntervalRef.current);
|
||||
refreshIntervalRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Check authentication status and load user data
|
||||
*/
|
||||
const checkAuth = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
// Check if we have a valid access token
|
||||
if (!tokenStorage.hasValidAccessToken()) {
|
||||
// Try to refresh if we have a refresh token
|
||||
if (!isRefreshTokenExpired()) {
|
||||
await refreshToken();
|
||||
} else {
|
||||
// No valid tokens, user is not authenticated
|
||||
setUser(null);
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Fetch current user data
|
||||
const userData = await authService.getCurrentUser();
|
||||
setUser(userData);
|
||||
|
||||
// Start token refresh interval
|
||||
startTokenRefreshInterval();
|
||||
} catch (error) {
|
||||
console.error('Auth check failed:', error);
|
||||
setUser(null);
|
||||
setError('Failed to verify authentication');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setIsInitialized(true);
|
||||
}
|
||||
}, [refreshToken, startTokenRefreshInterval]);
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
const login = useCallback(async (credentials: LoginCredentials) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.login(credentials);
|
||||
|
||||
// Fetch user data after successful login
|
||||
const userData = await authService.getCurrentUser();
|
||||
setUser(userData);
|
||||
|
||||
// Start token refresh interval
|
||||
startTokenRefreshInterval();
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Login failed';
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startTokenRefreshInterval]);
|
||||
|
||||
/**
|
||||
* Register a new user
|
||||
*/
|
||||
const register = useCallback(async (data: RegisterData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.register(data);
|
||||
|
||||
// Note: Django doesn't auto-login on registration
|
||||
// User needs to login after registration
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Registration failed';
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
const logout = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
await authService.logout();
|
||||
} catch (error) {
|
||||
console.error('Logout error:', error);
|
||||
} finally {
|
||||
setUser(null);
|
||||
stopTokenRefreshInterval();
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [stopTokenRefreshInterval]);
|
||||
|
||||
/**
|
||||
* Update user profile
|
||||
*/
|
||||
const updateProfile = useCallback(async (data: UpdateProfileData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
const updatedUser = await authService.updateProfile(data);
|
||||
setUser(updatedUser);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Profile update failed';
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Change password
|
||||
*/
|
||||
const changePassword = useCallback(async (data: ChangePasswordData) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.changePassword(data);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Password change failed';
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Request password reset
|
||||
*/
|
||||
const requestPasswordReset = useCallback(async (email: string) => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
await authService.requestPasswordReset(email);
|
||||
} catch (error: any) {
|
||||
const errorMessage = error.response?.data?.detail || error.message || 'Password reset request failed';
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Initialize authentication on mount
|
||||
useEffect(() => {
|
||||
checkAuth();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
stopTokenRefreshInterval();
|
||||
};
|
||||
}, [checkAuth, stopTokenRefreshInterval]);
|
||||
|
||||
const value: AuthContextType = {
|
||||
user,
|
||||
isAuthenticated: !!user,
|
||||
isLoading,
|
||||
isInitialized,
|
||||
error,
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshToken,
|
||||
updateProfile,
|
||||
changePassword,
|
||||
requestPasswordReset,
|
||||
checkAuth,
|
||||
};
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to use auth context
|
||||
*/
|
||||
export function useAuth() {
|
||||
const context = useContext(AuthContext);
|
||||
|
||||
if (context === undefined) {
|
||||
throw new Error('useAuth must be used within an AuthProvider');
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
27
lib/env.ts
Normal file
27
lib/env.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const envSchema = z.object({
|
||||
NEXT_PUBLIC_DJANGO_API_URL: z.string().url('NEXT_PUBLIC_DJANGO_API_URL must be a valid URL'),
|
||||
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID: z.string().min(1, 'NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID is required'),
|
||||
NEXT_PUBLIC_CLOUDFLARE_IMAGE_URL: z.string().url('NEXT_PUBLIC_CLOUDFLARE_IMAGE_URL must be a valid URL'),
|
||||
});
|
||||
|
||||
function validateEnv() {
|
||||
try {
|
||||
return envSchema.parse({
|
||||
NEXT_PUBLIC_DJANGO_API_URL: process.env.NEXT_PUBLIC_DJANGO_API_URL,
|
||||
NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID: process.env.NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_ID,
|
||||
NEXT_PUBLIC_CLOUDFLARE_IMAGE_URL: process.env.NEXT_PUBLIC_CLOUDFLARE_IMAGE_URL,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('❌ Invalid environment variables:');
|
||||
if (error instanceof z.ZodError) {
|
||||
error.errors.forEach((err) => {
|
||||
console.error(` - ${err.path.join('.')}: ${err.message}`);
|
||||
});
|
||||
}
|
||||
throw new Error('Environment validation failed');
|
||||
}
|
||||
}
|
||||
|
||||
export const env = validateEnv();
|
||||
237
lib/services/auth/authService.ts
Normal file
237
lib/services/auth/authService.ts
Normal file
@@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Authentication Service
|
||||
*
|
||||
* Core authentication service that handles login, registration, logout,
|
||||
* token refresh, and user profile management with the Django backend.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import {
|
||||
LoginCredentials,
|
||||
RegisterData,
|
||||
AuthResponse,
|
||||
User,
|
||||
ChangePasswordData,
|
||||
UpdateProfileData,
|
||||
UpdatePreferencesData,
|
||||
UserPreferences,
|
||||
UserRole,
|
||||
UserPermissions,
|
||||
UserStats,
|
||||
} from '@/lib/types/auth';
|
||||
import { tokenStorage } from './tokenStorage';
|
||||
|
||||
/**
|
||||
* Login with email and password
|
||||
*/
|
||||
export async function login(credentials: LoginCredentials): Promise<AuthResponse> {
|
||||
const response = await apiClient.post<AuthResponse>('/api/v1/auth/login', credentials);
|
||||
|
||||
// Store tokens
|
||||
tokenStorage.setAccessToken(response.data.access);
|
||||
tokenStorage.setRefreshToken(response.data.refresh);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user account
|
||||
*/
|
||||
export async function register(data: RegisterData): Promise<User> {
|
||||
const response = await apiClient.post<User>('/api/v1/auth/register', data);
|
||||
|
||||
// Note: Django returns 201 and user profile, but doesn't auto-login
|
||||
// The frontend should prompt to login after successful registration
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout and clear tokens
|
||||
*/
|
||||
export async function logout(): Promise<void> {
|
||||
try {
|
||||
// Call logout endpoint (blacklists refresh token on server)
|
||||
await apiClient.post('/api/v1/auth/logout');
|
||||
} catch (error) {
|
||||
// Continue with local logout even if server call fails
|
||||
console.error('Logout API call failed:', error);
|
||||
} finally {
|
||||
// Always clear local tokens
|
||||
tokenStorage.clearTokens();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh access token using refresh token
|
||||
*/
|
||||
export async function refreshAccessToken(): Promise<AuthResponse> {
|
||||
const refreshToken = tokenStorage.getRefreshToken();
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token available');
|
||||
}
|
||||
|
||||
const response = await apiClient.post<AuthResponse>('/api/v1/auth/token/refresh', {
|
||||
refresh: refreshToken,
|
||||
});
|
||||
|
||||
// Store new tokens
|
||||
tokenStorage.setAccessToken(response.data.access);
|
||||
tokenStorage.setRefreshToken(response.data.refresh);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user profile
|
||||
*/
|
||||
export async function getCurrentUser(): Promise<User> {
|
||||
const response = await apiClient.get<User>('/api/v1/auth/me');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user profile
|
||||
*/
|
||||
export async function updateProfile(data: UpdateProfileData): Promise<User> {
|
||||
const response = await apiClient.patch<User>('/api/v1/auth/me', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's role
|
||||
*/
|
||||
export async function getUserRole(): Promise<UserRole> {
|
||||
const response = await apiClient.get<UserRole>('/api/v1/auth/me/role');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's permissions
|
||||
*/
|
||||
export async function getUserPermissions(): Promise<UserPermissions> {
|
||||
const response = await apiClient.get<UserPermissions>('/api/v1/auth/me/permissions');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's statistics
|
||||
*/
|
||||
export async function getUserStats(): Promise<UserStats> {
|
||||
const response = await apiClient.get<UserStats>('/api/v1/auth/me/stats');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user's preferences
|
||||
*/
|
||||
export async function getUserPreferences(): Promise<UserPreferences> {
|
||||
const response = await apiClient.get<UserPreferences>('/api/v1/auth/me/preferences');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Update current user's preferences
|
||||
*/
|
||||
export async function updatePreferences(data: UpdatePreferencesData): Promise<UserPreferences> {
|
||||
const response = await apiClient.patch<UserPreferences>('/api/v1/auth/me/preferences', data);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change password for current user
|
||||
*/
|
||||
export async function changePassword(data: ChangePasswordData): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/password/change', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Request password reset email
|
||||
*/
|
||||
export async function requestPasswordReset(email: string): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/password/reset', { email });
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm password reset with token
|
||||
*/
|
||||
export async function confirmPasswordReset(
|
||||
token: string,
|
||||
password: string,
|
||||
passwordConfirm: string
|
||||
): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/password/reset/confirm', {
|
||||
token,
|
||||
password,
|
||||
password_confirm: passwordConfirm,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify email with token
|
||||
*/
|
||||
export async function verifyEmail(token: string): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/verify-email', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Resend email verification
|
||||
*/
|
||||
export async function resendVerification(): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/verify-email/resend');
|
||||
}
|
||||
|
||||
/**
|
||||
* Request email change
|
||||
*/
|
||||
export async function requestEmailChange(newEmail: string): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/change-email', {
|
||||
new_email: newEmail,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm email change with token
|
||||
*/
|
||||
export async function confirmEmailChange(token: string): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/change-email/confirm', { token });
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated
|
||||
*/
|
||||
export function isAuthenticated(): boolean {
|
||||
return tokenStorage.hasValidAccessToken();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get access token
|
||||
*/
|
||||
export function getAccessToken(): string | null {
|
||||
return tokenStorage.getAccessToken();
|
||||
}
|
||||
|
||||
// Export all functions as a service object for convenience
|
||||
export const authService = {
|
||||
login,
|
||||
register,
|
||||
logout,
|
||||
refreshAccessToken,
|
||||
getCurrentUser,
|
||||
updateProfile,
|
||||
getUserRole,
|
||||
getUserPermissions,
|
||||
getUserStats,
|
||||
getUserPreferences,
|
||||
updatePreferences,
|
||||
changePassword,
|
||||
requestPasswordReset,
|
||||
confirmPasswordReset,
|
||||
verifyEmail,
|
||||
resendVerification,
|
||||
requestEmailChange,
|
||||
confirmEmailChange,
|
||||
isAuthenticated,
|
||||
getAccessToken,
|
||||
};
|
||||
10
lib/services/auth/index.ts
Normal file
10
lib/services/auth/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Authentication Services Export
|
||||
*
|
||||
* Central export point for all authentication-related services.
|
||||
*/
|
||||
|
||||
export * from './authService';
|
||||
export * from './mfaService';
|
||||
export * from './oauthService';
|
||||
export * from './tokenStorage';
|
||||
210
lib/services/auth/mfaService.ts
Normal file
210
lib/services/auth/mfaService.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* MFA (Multi-Factor Authentication) Service
|
||||
*
|
||||
* Handles TOTP (Time-based One-Time Password) setup, verification,
|
||||
* and management for two-factor authentication.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import {
|
||||
TOTPSetupResponse,
|
||||
TOTPConfirmData,
|
||||
TOTPVerifyData,
|
||||
MFAChallengeData,
|
||||
AuthResponse,
|
||||
} from '@/lib/types/auth';
|
||||
import { tokenStorage } from './tokenStorage';
|
||||
|
||||
/**
|
||||
* Enable MFA/2FA for current user
|
||||
* Returns TOTP secret and QR code URL
|
||||
*/
|
||||
export async function setupTOTP(): Promise<TOTPSetupResponse> {
|
||||
const response = await apiClient.post<TOTPSetupResponse>('/api/v1/auth/mfa/enable');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm MFA setup with verification token
|
||||
* Completes MFA setup after verifying the token is valid
|
||||
*/
|
||||
export async function confirmTOTP(data: TOTPConfirmData): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/mfa/confirm', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disable MFA/2FA for current user
|
||||
* Removes all TOTP devices and disables MFA requirement
|
||||
*/
|
||||
export async function disableTOTP(): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/mfa/disable');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify MFA token (for testing)
|
||||
* Returns whether the token is valid
|
||||
*/
|
||||
export async function verifyTOTP(data: TOTPVerifyData): Promise<boolean> {
|
||||
try {
|
||||
await apiClient.post('/api/v1/auth/mfa/verify', data);
|
||||
return true;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete MFA challenge during login
|
||||
* Called when user has MFA enabled and needs to provide token
|
||||
*/
|
||||
export async function challengeMFA(data: MFAChallengeData): Promise<AuthResponse> {
|
||||
const response = await apiClient.post<AuthResponse>('/api/v1/auth/mfa/challenge', {
|
||||
token: data.code,
|
||||
});
|
||||
|
||||
// Store tokens after successful MFA challenge
|
||||
tokenStorage.setAccessToken(response.data.access);
|
||||
tokenStorage.setRefreshToken(response.data.refresh);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate new backup codes
|
||||
* Returns a list of one-time use backup codes
|
||||
*/
|
||||
export async function generateBackupCodes(): Promise<string[]> {
|
||||
const response = await apiClient.post<{ backup_codes: string[] }>('/api/v1/auth/mfa/backup-codes');
|
||||
return response.data.backup_codes;
|
||||
}
|
||||
|
||||
/**
|
||||
* Use a backup code to login when TOTP is unavailable
|
||||
*/
|
||||
export async function useBackupCode(code: string): Promise<AuthResponse> {
|
||||
const response = await apiClient.post<AuthResponse>('/api/v1/auth/mfa/backup-code', {
|
||||
code,
|
||||
});
|
||||
|
||||
// Store tokens after successful backup code use
|
||||
tokenStorage.setAccessToken(response.data.access);
|
||||
tokenStorage.setRefreshToken(response.data.refresh);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove MFA with password confirmation
|
||||
*/
|
||||
export async function removeMFA(password: string): Promise<void> {
|
||||
await apiClient.post('/api/v1/auth/mfa/remove', {
|
||||
password,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* WebAuthn/Passkey Support
|
||||
* Django-allauth provides WebAuthn through its MFA module
|
||||
*/
|
||||
|
||||
export interface WebAuthnCredential {
|
||||
id: string;
|
||||
name: string;
|
||||
created_at: string;
|
||||
last_used?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of registered WebAuthn credentials (passkeys)
|
||||
*/
|
||||
export async function getWebAuthnCredentials(): Promise<WebAuthnCredential[]> {
|
||||
const response = await apiClient.get<{ credentials: WebAuthnCredential[] }>(
|
||||
'/accounts/mfa/webauthn/list/'
|
||||
);
|
||||
return response.data.credentials;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start WebAuthn registration process
|
||||
* Returns challenge data for credential creation
|
||||
*/
|
||||
export async function startWebAuthnRegistration(): Promise<PublicKeyCredentialCreationOptions> {
|
||||
const response = await apiClient.get<PublicKeyCredentialCreationOptions>(
|
||||
'/accounts/mfa/webauthn/add/'
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete WebAuthn registration
|
||||
* @param credential The created PublicKeyCredential from browser
|
||||
* @param name Optional friendly name for the credential
|
||||
*/
|
||||
export async function completeWebAuthnRegistration(
|
||||
credential: PublicKeyCredential,
|
||||
name?: string
|
||||
): Promise<void> {
|
||||
await apiClient.post('/accounts/mfa/webauthn/add/', {
|
||||
credential: JSON.stringify(credential),
|
||||
name,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove a WebAuthn credential
|
||||
* @param credentialId The ID of the credential to remove
|
||||
*/
|
||||
export async function removeWebAuthnCredential(credentialId: string): Promise<void> {
|
||||
await apiClient.post(`/accounts/mfa/webauthn/remove/${credentialId}/`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start WebAuthn authentication challenge
|
||||
* Used during login when user has passkey enabled
|
||||
*/
|
||||
export async function startWebAuthnAuthentication(): Promise<PublicKeyCredentialRequestOptions> {
|
||||
const response = await apiClient.get<PublicKeyCredentialRequestOptions>(
|
||||
'/accounts/mfa/webauthn/authenticate/'
|
||||
);
|
||||
return response.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete WebAuthn authentication
|
||||
* @param credential The assertion credential from browser
|
||||
*/
|
||||
export async function completeWebAuthnAuthentication(
|
||||
credential: PublicKeyCredential
|
||||
): Promise<AuthResponse> {
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
'/accounts/mfa/webauthn/authenticate/',
|
||||
{
|
||||
credential: JSON.stringify(credential),
|
||||
}
|
||||
);
|
||||
|
||||
// Store tokens after successful WebAuthn authentication
|
||||
tokenStorage.setAccessToken(response.data.access);
|
||||
tokenStorage.setRefreshToken(response.data.refresh);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
// Export all functions as a service object for convenience
|
||||
export const mfaService = {
|
||||
setupTOTP,
|
||||
confirmTOTP,
|
||||
disableTOTP,
|
||||
verifyTOTP,
|
||||
challengeMFA,
|
||||
generateBackupCodes,
|
||||
useBackupCode,
|
||||
removeMFA,
|
||||
// WebAuthn/Passkey methods
|
||||
getWebAuthnCredentials,
|
||||
startWebAuthnRegistration,
|
||||
completeWebAuthnRegistration,
|
||||
removeWebAuthnCredential,
|
||||
startWebAuthnAuthentication,
|
||||
completeWebAuthnAuthentication,
|
||||
};
|
||||
174
lib/services/auth/oauthService.ts
Normal file
174
lib/services/auth/oauthService.ts
Normal file
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* OAuth Service
|
||||
*
|
||||
* Handles OAuth authentication flow with Google and Discord providers.
|
||||
* Works with django-allauth backend for seamless OAuth integration.
|
||||
*/
|
||||
|
||||
import { apiClient } from '@/lib/api/client';
|
||||
import { AuthResponse } from '@/lib/types/auth';
|
||||
import { tokenStorage } from './tokenStorage';
|
||||
|
||||
export type OAuthProvider = 'google' | 'discord';
|
||||
|
||||
/**
|
||||
* OAuth state management for CSRF protection
|
||||
*/
|
||||
const OAUTH_STATE_KEY = 'oauth_state';
|
||||
const OAUTH_REDIRECT_KEY = 'oauth_redirect';
|
||||
|
||||
/**
|
||||
* Generate random state for OAuth CSRF protection
|
||||
*/
|
||||
function generateState(): string {
|
||||
const array = new Uint8Array(32);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Store OAuth state in sessionStorage
|
||||
*/
|
||||
function storeState(state: string, redirectUrl?: string): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
|
||||
sessionStorage.setItem(OAUTH_STATE_KEY, state);
|
||||
if (redirectUrl) {
|
||||
sessionStorage.setItem(OAUTH_REDIRECT_KEY, redirectUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve and validate OAuth state
|
||||
*/
|
||||
function validateState(state: string): boolean {
|
||||
if (typeof window === 'undefined') return false;
|
||||
|
||||
const storedState = sessionStorage.getItem(OAUTH_STATE_KEY);
|
||||
sessionStorage.removeItem(OAUTH_STATE_KEY);
|
||||
|
||||
return storedState === state;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored redirect URL
|
||||
*/
|
||||
function getRedirectUrl(): string | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
|
||||
const redirectUrl = sessionStorage.getItem(OAUTH_REDIRECT_KEY);
|
||||
sessionStorage.removeItem(OAUTH_REDIRECT_KEY);
|
||||
|
||||
return redirectUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initiate OAuth flow for a provider
|
||||
*
|
||||
* @param provider - OAuth provider ('google' or 'discord')
|
||||
* @param redirectUrl - Optional URL to redirect to after successful OAuth
|
||||
*/
|
||||
export async function initiateOAuth(
|
||||
provider: OAuthProvider,
|
||||
redirectUrl?: string
|
||||
): Promise<void> {
|
||||
// Generate state for CSRF protection
|
||||
const state = generateState();
|
||||
storeState(state, redirectUrl);
|
||||
|
||||
// Build OAuth initiation URL
|
||||
const callbackUrl = `${window.location.origin}/auth/oauth/callback`;
|
||||
const params = new URLSearchParams({
|
||||
state,
|
||||
redirect_uri: callbackUrl,
|
||||
});
|
||||
|
||||
// Redirect to OAuth provider via Django backend
|
||||
const oauthUrl = `${process.env.NEXT_PUBLIC_DJANGO_API_URL}/api/v1/auth/oauth/${provider}/?${params}`;
|
||||
window.location.href = oauthUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle OAuth callback after provider authentication
|
||||
*
|
||||
* @param provider - OAuth provider
|
||||
* @param code - Authorization code from provider
|
||||
* @param state - State parameter for CSRF validation
|
||||
* @returns Auth response with JWT tokens and user info
|
||||
*/
|
||||
export async function handleOAuthCallback(
|
||||
provider: OAuthProvider,
|
||||
code: string,
|
||||
state: string
|
||||
): Promise<{ authResponse: AuthResponse; redirectUrl: string | null }> {
|
||||
// Validate state to prevent CSRF attacks
|
||||
if (!validateState(state)) {
|
||||
throw new Error('Invalid OAuth state - possible CSRF attack');
|
||||
}
|
||||
|
||||
// Exchange code for JWT tokens
|
||||
const response = await apiClient.post<AuthResponse>(
|
||||
`/api/v1/auth/oauth/${provider}/callback`,
|
||||
{ code, state }
|
||||
);
|
||||
|
||||
// Store tokens
|
||||
tokenStorage.setAccessToken(response.data.access);
|
||||
tokenStorage.setRefreshToken(response.data.refresh);
|
||||
|
||||
// Get redirect URL if stored
|
||||
const redirectUrl = getRedirectUrl();
|
||||
|
||||
return {
|
||||
authResponse: response.data,
|
||||
redirectUrl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Link OAuth provider to existing account
|
||||
*
|
||||
* @param provider - OAuth provider to link
|
||||
*/
|
||||
export async function linkOAuthProvider(provider: OAuthProvider): Promise<void> {
|
||||
const state = generateState();
|
||||
storeState(state);
|
||||
|
||||
const callbackUrl = `${window.location.origin}/auth/oauth/link/callback`;
|
||||
const params = new URLSearchParams({
|
||||
state,
|
||||
redirect_uri: callbackUrl,
|
||||
link: 'true',
|
||||
});
|
||||
|
||||
const oauthUrl = `${process.env.NEXT_PUBLIC_DJANGO_API_URL}/api/v1/auth/oauth/${provider}/?${params}`;
|
||||
window.location.href = oauthUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unlink OAuth provider from account
|
||||
*
|
||||
* @param provider - OAuth provider to unlink
|
||||
*/
|
||||
export async function unlinkOAuthProvider(provider: OAuthProvider): Promise<void> {
|
||||
await apiClient.post(`/api/v1/auth/oauth/${provider}/unlink`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of linked OAuth providers
|
||||
*/
|
||||
export async function getLinkedProviders(): Promise<OAuthProvider[]> {
|
||||
const response = await apiClient.get<{ providers: OAuthProvider[] }>(
|
||||
'/api/v1/auth/oauth/linked'
|
||||
);
|
||||
return response.data.providers;
|
||||
}
|
||||
|
||||
// Export all functions as a service object
|
||||
export const oauthService = {
|
||||
initiateOAuth,
|
||||
handleOAuthCallback,
|
||||
linkOAuthProvider,
|
||||
unlinkOAuthProvider,
|
||||
getLinkedProviders,
|
||||
};
|
||||
141
lib/services/auth/tokenStorage.ts
Normal file
141
lib/services/auth/tokenStorage.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Token Storage Utility
|
||||
*
|
||||
* Handles JWT token storage, retrieval, and validation in localStorage.
|
||||
*/
|
||||
|
||||
import { TokenPayload, TokenStorage } from '@/lib/types/auth';
|
||||
|
||||
// Storage keys
|
||||
const ACCESS_TOKEN_KEY = 'auth_access_token';
|
||||
const REFRESH_TOKEN_KEY = 'auth_refresh_token';
|
||||
|
||||
/**
|
||||
* Decode JWT token payload without verification
|
||||
* (Verification happens on the server)
|
||||
*/
|
||||
function decodeToken(token: string): TokenPayload | null {
|
||||
try {
|
||||
const parts = token.split('.');
|
||||
if (parts.length !== 3) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = parts[1];
|
||||
const decoded = JSON.parse(atob(payload));
|
||||
|
||||
return decoded as TokenPayload;
|
||||
} catch (error) {
|
||||
console.error('Failed to decode token:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a token is expired
|
||||
*/
|
||||
function isTokenExpired(token: string): boolean {
|
||||
const decoded = decodeToken(token);
|
||||
if (!decoded || !decoded.exp) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if token expires in the next 60 seconds (add buffer)
|
||||
const expiryTime = decoded.exp * 1000; // Convert to milliseconds
|
||||
const now = Date.now();
|
||||
const bufferTime = 60 * 1000; // 60 seconds buffer
|
||||
|
||||
return now >= (expiryTime - bufferTime);
|
||||
}
|
||||
|
||||
/**
|
||||
* Token storage implementation
|
||||
*/
|
||||
export const tokenStorage: TokenStorage = {
|
||||
getAccessToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(ACCESS_TOKEN_KEY);
|
||||
},
|
||||
|
||||
setAccessToken(token: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(ACCESS_TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
getRefreshToken(): string | null {
|
||||
if (typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
return localStorage.getItem(REFRESH_TOKEN_KEY);
|
||||
},
|
||||
|
||||
setRefreshToken(token: string): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, token);
|
||||
},
|
||||
|
||||
clearTokens(): void {
|
||||
if (typeof window === 'undefined') {
|
||||
return;
|
||||
}
|
||||
localStorage.removeItem(ACCESS_TOKEN_KEY);
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY);
|
||||
},
|
||||
|
||||
hasValidAccessToken(): boolean {
|
||||
const token = this.getAccessToken();
|
||||
if (!token) {
|
||||
return false;
|
||||
}
|
||||
return !isTokenExpired(token);
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the decoded payload from the access token
|
||||
*/
|
||||
export function getTokenPayload(): TokenPayload | null {
|
||||
const token = tokenStorage.getAccessToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
return decodeToken(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the refresh token is expired
|
||||
*/
|
||||
export function isRefreshTokenExpired(): boolean {
|
||||
const token = tokenStorage.getRefreshToken();
|
||||
if (!token) {
|
||||
return true;
|
||||
}
|
||||
return isTokenExpired(token);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get time until access token expires (in milliseconds)
|
||||
*/
|
||||
export function getTimeUntilExpiry(): number | null {
|
||||
const token = tokenStorage.getAccessToken();
|
||||
if (!token) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const decoded = decodeToken(token);
|
||||
if (!decoded || !decoded.exp) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expiryTime = decoded.exp * 1000;
|
||||
const now = Date.now();
|
||||
const timeRemaining = expiryTime - now;
|
||||
|
||||
return timeRemaining > 0 ? timeRemaining : 0;
|
||||
}
|
||||
206
lib/types/auth.ts
Normal file
206
lib/types/auth.ts
Normal file
@@ -0,0 +1,206 @@
|
||||
/**
|
||||
* Authentication Type Definitions
|
||||
*
|
||||
* Types for user authentication, registration, and profile management.
|
||||
* These types align with the Django backend API schemas.
|
||||
*/
|
||||
|
||||
// ============================================================================
|
||||
// User Types
|
||||
// ============================================================================
|
||||
|
||||
export interface User {
|
||||
id: string;
|
||||
email: string;
|
||||
username: string;
|
||||
first_name: string;
|
||||
last_name: string;
|
||||
display_name: string;
|
||||
avatar_url: string | null;
|
||||
bio: string | null;
|
||||
reputation_score: number;
|
||||
mfa_enabled: boolean;
|
||||
banned: boolean;
|
||||
date_joined: string;
|
||||
last_login: string | null;
|
||||
oauth_provider: string;
|
||||
}
|
||||
|
||||
export interface UserProfile extends User {
|
||||
// Extended profile fields
|
||||
role?: UserRole;
|
||||
permissions?: UserPermissions;
|
||||
stats?: UserStats;
|
||||
preferences?: UserPreferences;
|
||||
}
|
||||
|
||||
export interface UserRole {
|
||||
role: 'user' | 'moderator' | 'admin';
|
||||
is_moderator: boolean;
|
||||
is_admin: boolean;
|
||||
granted_at: string;
|
||||
granted_by_email: string | null;
|
||||
}
|
||||
|
||||
export interface UserPermissions {
|
||||
can_submit: boolean;
|
||||
can_moderate: boolean;
|
||||
can_admin: boolean;
|
||||
can_edit_own: boolean;
|
||||
can_delete_own: boolean;
|
||||
}
|
||||
|
||||
export interface UserStats {
|
||||
total_submissions: number;
|
||||
approved_submissions: number;
|
||||
reputation_score: number;
|
||||
member_since: string;
|
||||
last_active: string | null;
|
||||
}
|
||||
|
||||
export interface UserPreferences {
|
||||
email_notifications: boolean;
|
||||
email_on_submission_approved: boolean;
|
||||
email_on_submission_rejected: boolean;
|
||||
profile_public: boolean;
|
||||
show_email: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Authentication Request/Response Types
|
||||
// ============================================================================
|
||||
|
||||
export interface LoginCredentials {
|
||||
email: string;
|
||||
password: string;
|
||||
mfa_token?: string;
|
||||
}
|
||||
|
||||
export interface RegisterData {
|
||||
email: string;
|
||||
password: string;
|
||||
password_confirm: string;
|
||||
username?: string;
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
}
|
||||
|
||||
export interface AuthResponse {
|
||||
access: string;
|
||||
refresh: string;
|
||||
token_type: string;
|
||||
}
|
||||
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
export interface ChangePasswordData {
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
new_password_confirm: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordData {
|
||||
email: string;
|
||||
}
|
||||
|
||||
export interface ResetPasswordConfirmData {
|
||||
token: string;
|
||||
password: string;
|
||||
password_confirm: string;
|
||||
}
|
||||
|
||||
export interface UpdateProfileData {
|
||||
first_name?: string;
|
||||
last_name?: string;
|
||||
username?: string;
|
||||
bio?: string;
|
||||
avatar_url?: string;
|
||||
}
|
||||
|
||||
export interface UpdatePreferencesData {
|
||||
email_notifications?: boolean;
|
||||
email_on_submission_approved?: boolean;
|
||||
email_on_submission_rejected?: boolean;
|
||||
profile_public?: boolean;
|
||||
show_email?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MFA/2FA Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TOTPSetupResponse {
|
||||
secret: string;
|
||||
qr_code_url: string;
|
||||
backup_codes: string[];
|
||||
}
|
||||
|
||||
export interface TOTPConfirmData {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface TOTPVerifyData {
|
||||
token: string;
|
||||
}
|
||||
|
||||
export interface MFAChallengeData {
|
||||
code: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// OAuth Types (for future implementation)
|
||||
// ============================================================================
|
||||
|
||||
export type OAuthProvider = 'google' | 'github';
|
||||
|
||||
export interface OAuthCallbackData {
|
||||
code: string;
|
||||
state: string;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Auth State Types
|
||||
// ============================================================================
|
||||
|
||||
export interface AuthState {
|
||||
user: User | null;
|
||||
isAuthenticated: boolean;
|
||||
isLoading: boolean;
|
||||
isInitialized: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export interface AuthContextType extends AuthState {
|
||||
login: (credentials: LoginCredentials) => Promise<void>;
|
||||
register: (data: RegisterData) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
refreshToken: () => Promise<void>;
|
||||
updateProfile: (data: UpdateProfileData) => Promise<void>;
|
||||
changePassword: (data: ChangePasswordData) => Promise<void>;
|
||||
requestPasswordReset: (email: string) => Promise<void>;
|
||||
checkAuth: () => Promise<void>;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Token Management Types
|
||||
// ============================================================================
|
||||
|
||||
export interface TokenPayload {
|
||||
user_id: string;
|
||||
email: string;
|
||||
exp: number;
|
||||
iat: number;
|
||||
token_type: 'access' | 'refresh';
|
||||
}
|
||||
|
||||
export interface TokenStorage {
|
||||
getAccessToken: () => string | null;
|
||||
setAccessToken: (token: string) => void;
|
||||
getRefreshToken: () => string | null;
|
||||
setRefreshToken: (token: string) => void;
|
||||
clearTokens: () => void;
|
||||
hasValidAccessToken: () => boolean;
|
||||
}
|
||||
6
lib/utils.ts
Normal file
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx';
|
||||
import { twMerge } from 'tailwind-merge';
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
Reference in New Issue
Block a user