Refactor code structure and remove redundant changes

This commit is contained in:
pacnpal
2025-11-09 16:31:34 -05:00
parent 2884bc23ce
commit eb68cf40c6
1080 changed files with 27361 additions and 56687 deletions

163
lib/api/client.ts Normal file
View 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
View 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
View 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
View 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>);
}

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

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

View 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';

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

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

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