509 lines
19 KiB
TypeScript
509 lines
19 KiB
TypeScript
/**
|
|
* Authentication Store (Zustand)
|
|
* Manages authentication state, user, and account information
|
|
*/
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
import { fetchAPI } from '../services/api';
|
|
|
|
type AuthErrorCode = 'ACCOUNT_REQUIRED' | 'PLAN_REQUIRED' | 'AUTH_FAILED';
|
|
|
|
function createAuthError(message: string, code: AuthErrorCode): Error & { code: AuthErrorCode } {
|
|
const error = new Error(message) as Error & { code: AuthErrorCode };
|
|
error.code = code;
|
|
return error;
|
|
}
|
|
|
|
interface User {
|
|
id: number;
|
|
email: string;
|
|
username: string;
|
|
role: string;
|
|
account?: {
|
|
id: number;
|
|
name: string;
|
|
slug: string;
|
|
credits: number;
|
|
status: string;
|
|
plan?: any; // plan info is optional but required for access gating
|
|
};
|
|
}
|
|
|
|
interface AuthState {
|
|
user: User | null;
|
|
token: string | null;
|
|
refreshToken: string | null;
|
|
isAuthenticated: boolean;
|
|
loading: boolean;
|
|
|
|
// Actions
|
|
login: (email: string, password: string) => Promise<void>;
|
|
logout: () => void;
|
|
register: (data: any) => Promise<void>;
|
|
setUser: (user: User | null) => void;
|
|
setToken: (token: string | null) => void;
|
|
refreshToken: () => Promise<void>;
|
|
refreshUser: () => Promise<void>;
|
|
}
|
|
|
|
export const useAuthStore = create<AuthState>()(
|
|
persist<AuthState>(
|
|
(set, get) => ({
|
|
user: null,
|
|
token: null,
|
|
isAuthenticated: false,
|
|
loading: false, // Always start with loading false - will be set true only during login/register
|
|
|
|
login: async (email, password, rememberMe = false) => {
|
|
set({ loading: true });
|
|
try {
|
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
|
const response = await fetch(`${API_BASE_URL}/v1/auth/login/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ email, password, remember_me: rememberMe }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
const message = data.error || data.message || 'Login failed';
|
|
if (response.status === 402) {
|
|
throw createAuthError(message, 'PLAN_REQUIRED');
|
|
}
|
|
if (response.status === 403) {
|
|
throw createAuthError(message, 'ACCOUNT_REQUIRED');
|
|
}
|
|
throw createAuthError(message, 'AUTH_FAILED');
|
|
}
|
|
|
|
// Store user and JWT tokens (handle both old and new API formats)
|
|
const responseData = data.data || data;
|
|
// Support both formats: new (access/refresh at top level) and old (tokens.access/refresh)
|
|
const tokens = responseData.tokens || {};
|
|
const userData = responseData.user || data.user;
|
|
|
|
if (!userData?.account) {
|
|
throw createAuthError('Account not configured for this user. Please contact support.', 'ACCOUNT_REQUIRED');
|
|
}
|
|
if (!userData.account.plan) {
|
|
throw createAuthError('Active subscription required. Visit igny8.com/pricing to subscribe.', 'PLAN_REQUIRED');
|
|
}
|
|
|
|
const newToken = responseData.access || tokens.access || data.access || null;
|
|
const newRefreshToken = responseData.refresh || tokens.refresh || data.refresh || null;
|
|
|
|
// CRITICAL: Set auth state AND immediately persist to localStorage
|
|
// This prevents race conditions where API calls happen before persist middleware writes
|
|
set({
|
|
user: userData,
|
|
token: newToken,
|
|
refreshToken: newRefreshToken,
|
|
isAuthenticated: true,
|
|
loading: false
|
|
});
|
|
|
|
// Force immediate persist to localStorage (don't wait for Zustand middleware)
|
|
try {
|
|
const authState = {
|
|
state: {
|
|
user: userData,
|
|
token: newToken,
|
|
refreshToken: newRefreshToken,
|
|
isAuthenticated: true,
|
|
loading: false
|
|
},
|
|
version: 0
|
|
};
|
|
localStorage.setItem('auth-storage', JSON.stringify(authState));
|
|
|
|
// CRITICAL: Also set tokens as separate items for API interceptor
|
|
if (newToken) {
|
|
localStorage.setItem('access_token', newToken);
|
|
}
|
|
if (newRefreshToken) {
|
|
localStorage.setItem('refresh_token', newRefreshToken);
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to persist auth state to localStorage:', e);
|
|
}
|
|
} catch (error: any) {
|
|
// ALWAYS reset loading on error - critical to prevent stuck state
|
|
set({ loading: false });
|
|
throw new Error(error.message || 'Login failed');
|
|
} finally {
|
|
// Extra safety: ensure loading is ALWAYS false after login attempt completes
|
|
// This handles edge cases like network timeouts, browser crashes, etc.
|
|
const current = get();
|
|
if (current.loading) {
|
|
set({ loading: false });
|
|
}
|
|
}
|
|
},
|
|
|
|
logout: () => {
|
|
// Check if there's already a logout reason from automatic logout
|
|
const existingReason = localStorage.getItem('logout_reason');
|
|
|
|
if (!existingReason) {
|
|
// Only store manual logout reason if no automatic reason exists
|
|
const currentPath = typeof window !== 'undefined' ? window.location.pathname : 'unknown';
|
|
const logoutContext = {
|
|
code: 'MANUAL_LOGOUT',
|
|
message: 'You have been logged out',
|
|
path: currentPath,
|
|
context: {
|
|
user: get().user?.email || 'unknown',
|
|
timestamp: new Date().toISOString(),
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
source: 'manual_user_action'
|
|
};
|
|
|
|
console.error('🚪 MANUAL LOGOUT from page:', currentPath);
|
|
|
|
try {
|
|
localStorage.setItem('logout_reason', JSON.stringify(logoutContext));
|
|
console.error('✅ Stored manual logout_reason');
|
|
} catch (e) {
|
|
console.error('❌ Failed to store logout reason:', e);
|
|
}
|
|
} else {
|
|
console.error('⚠️ Automatic logout reason already exists, not overwriting with manual logout');
|
|
console.error('Existing reason:', existingReason);
|
|
}
|
|
|
|
// CRITICAL: Properly clear ALL cookies to prevent session contamination
|
|
const cookies = document.cookie.split(";");
|
|
for (let i = 0; i < cookies.length; i++) {
|
|
const cookie = cookies[i];
|
|
const eqPos = cookie.indexOf("=");
|
|
const name = eqPos > -1 ? cookie.substr(0, eqPos).trim() : cookie.trim();
|
|
// Clear cookie for all possible domains and paths
|
|
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/";
|
|
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=" + window.location.hostname;
|
|
document.cookie = name + "=;expires=Thu, 01 Jan 1970 00:00:00 GMT;path=/;domain=." + window.location.hostname;
|
|
}
|
|
|
|
// IMPORTANT: Selectively clear auth-related localStorage items
|
|
// DO NOT clear 'logout_reason' - it needs to persist for signin page display!
|
|
// DO NOT use localStorage.clear() as it breaks Zustand persist middleware
|
|
const authKeys = ['auth-storage', 'auth-store', 'site-storage', 'sector-storage', 'billing-storage', 'access_token', 'refresh_token'];
|
|
authKeys.forEach(key => {
|
|
try {
|
|
localStorage.removeItem(key);
|
|
} catch (e) {
|
|
console.warn(`Failed to remove ${key}:`, e);
|
|
}
|
|
});
|
|
|
|
// Clear sessionStorage
|
|
sessionStorage.clear();
|
|
|
|
// Reset auth state to initial values
|
|
set({
|
|
user: null,
|
|
token: null,
|
|
refreshToken: null,
|
|
isAuthenticated: false,
|
|
loading: false
|
|
});
|
|
|
|
// Reset other stores that depend on auth
|
|
try {
|
|
// Dynamically import and reset site store
|
|
import('./siteStore').then(({ useSiteStore }) => {
|
|
useSiteStore.setState({ activeSite: null, loading: false, error: null });
|
|
});
|
|
|
|
// Dynamically import and reset sector store
|
|
import('./sectorStore').then(({ useSectorStore }) => {
|
|
useSectorStore.setState({ activeSector: null, sectors: [], loading: false, error: null });
|
|
});
|
|
|
|
// Dynamically import and reset billing store
|
|
import('./billingStore').then(({ useBillingStore }) => {
|
|
useBillingStore.setState({ balance: null, loading: false, error: null });
|
|
});
|
|
} catch (e) {
|
|
console.warn('Failed to reset stores on logout:', e);
|
|
}
|
|
},
|
|
|
|
register: async (registerData) => {
|
|
set({ loading: true });
|
|
try {
|
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
|
const response = await fetch(`${API_BASE_URL}/v1/auth/register/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
...registerData,
|
|
password_confirm: registerData.password, // Add password_confirm
|
|
plan_slug: registerData.plan_slug,
|
|
}),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
// Handle validation errors
|
|
const errorMessage = data.message ||
|
|
(data.errors && typeof data.errors === 'object'
|
|
? JSON.stringify(data.errors)
|
|
: data.errors) ||
|
|
'Registration failed';
|
|
throw new Error(errorMessage);
|
|
}
|
|
|
|
// Store user and JWT tokens (handle nested tokens structure)
|
|
const responseData = data.data || data;
|
|
const tokens = responseData.tokens || {};
|
|
const userData = responseData.user || data.user;
|
|
|
|
// Extract tokens with multiple fallbacks
|
|
// Response format: { success: true, data: { user: {...}, tokens: { access, refresh } } }
|
|
const newToken =
|
|
tokens.access ||
|
|
responseData.access ||
|
|
data.access ||
|
|
data.data?.tokens?.access ||
|
|
data.tokens?.access ||
|
|
null;
|
|
|
|
const newRefreshToken =
|
|
tokens.refresh ||
|
|
responseData.refresh ||
|
|
data.refresh ||
|
|
data.data?.tokens?.refresh ||
|
|
data.tokens?.refresh ||
|
|
null;
|
|
|
|
console.log('Registration response parsed:', {
|
|
hasUserData: !!userData,
|
|
hasAccessToken: !!newToken,
|
|
hasRefreshToken: !!newRefreshToken,
|
|
userEmail: userData?.email,
|
|
accountId: userData?.account?.id,
|
|
tokensLocation: tokens.access ? 'tokens.access' :
|
|
responseData.access ? 'responseData.access' :
|
|
data.data?.tokens?.access ? 'data.data.tokens.access' : 'not found'
|
|
});
|
|
|
|
if (!newToken || !userData) {
|
|
console.error('Registration succeeded but missing critical data:', {
|
|
token: newToken,
|
|
user: userData,
|
|
fullResponse: data,
|
|
parsedTokens: tokens,
|
|
parsedResponseData: responseData
|
|
});
|
|
throw new Error('Registration completed but authentication failed. Please try logging in.');
|
|
}
|
|
|
|
// CRITICAL: Set auth state AND immediately persist to localStorage
|
|
// This prevents race conditions where navigation happens before persist
|
|
set({
|
|
user: userData,
|
|
token: newToken,
|
|
refreshToken: newRefreshToken,
|
|
isAuthenticated: true,
|
|
loading: false
|
|
});
|
|
|
|
// Force immediate persist to localStorage (don't wait for Zustand middleware)
|
|
try {
|
|
const authState = {
|
|
state: {
|
|
user: userData,
|
|
token: newToken,
|
|
refreshToken: newRefreshToken,
|
|
isAuthenticated: true,
|
|
loading: false
|
|
},
|
|
version: 0
|
|
};
|
|
localStorage.setItem('auth-storage', JSON.stringify(authState));
|
|
|
|
// CRITICAL: Also set tokens as separate items for API interceptor
|
|
// This ensures fetchAPI can access tokens immediately
|
|
if (newToken) {
|
|
localStorage.setItem('access_token', newToken);
|
|
}
|
|
if (newRefreshToken) {
|
|
localStorage.setItem('refresh_token', newRefreshToken);
|
|
}
|
|
|
|
console.log('Auth state persisted to localStorage successfully');
|
|
} catch (e) {
|
|
console.error('CRITICAL: Failed to persist auth state to localStorage:', e);
|
|
throw new Error('Failed to save login session. Please try again.');
|
|
}
|
|
|
|
// Return user data for success handling
|
|
return userData;
|
|
} catch (error: any) {
|
|
// ALWAYS reset loading on error - critical to prevent stuck state
|
|
set({ loading: false });
|
|
throw new Error(error.message || 'Registration failed');
|
|
} finally {
|
|
// Extra safety: ensure loading is ALWAYS false after register attempt completes
|
|
const current = get();
|
|
if (current.loading) {
|
|
set({ loading: false });
|
|
}
|
|
}
|
|
},
|
|
|
|
setUser: (user) => {
|
|
set({ user, isAuthenticated: !!user });
|
|
},
|
|
|
|
setToken: (token) => {
|
|
set({ token });
|
|
},
|
|
|
|
refreshToken: async () => {
|
|
const state = get();
|
|
if (!state.refreshToken) {
|
|
throw new Error('No refresh token available');
|
|
}
|
|
|
|
try {
|
|
const API_BASE_URL = import.meta.env.VITE_BACKEND_URL || 'https://api.igny8.com/api';
|
|
const response = await fetch(`${API_BASE_URL}/v1/auth/refresh/`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ refresh: state.refreshToken }),
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (!response.ok || !data.success) {
|
|
throw new Error(data.message || 'Token refresh failed');
|
|
}
|
|
|
|
// Update access token (API returns access at top level of data)
|
|
set({ token: data.data?.access || data.access });
|
|
|
|
// Also refresh user data to get latest account/plan information
|
|
// This ensures account/plan changes are reflected immediately
|
|
try {
|
|
await get().refreshUser();
|
|
} catch (userRefreshError) {
|
|
// If user refresh fails, don't fail the token refresh
|
|
console.warn('Failed to refresh user data during token refresh:', userRefreshError);
|
|
}
|
|
} catch (error: any) {
|
|
// If refresh fails, logout user
|
|
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
|
|
throw new Error(error.message || 'Token refresh failed');
|
|
}
|
|
},
|
|
|
|
refreshUser: async () => {
|
|
const state = get();
|
|
if (!state.token && !state.isAuthenticated) {
|
|
throw new Error('Not authenticated');
|
|
}
|
|
|
|
try {
|
|
const response = await fetchAPI('/v1/auth/me/');
|
|
if (!response || !response.user) {
|
|
throw new Error('Failed to refresh user data');
|
|
}
|
|
|
|
const refreshedUser = response.user;
|
|
if (!refreshedUser.account) {
|
|
const logoutReasonData = {
|
|
code: 'ACCOUNT_REQUIRED',
|
|
message: 'Account not configured for this user. Please contact support.',
|
|
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
|
|
context: {
|
|
user_email: refreshedUser.email,
|
|
user_id: refreshedUser.id
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
source: 'refresh_user_validation'
|
|
};
|
|
console.error('🚨 LOGOUT TRIGGERED - Account Required:', logoutReasonData);
|
|
localStorage.setItem('logout_reason', JSON.stringify(logoutReasonData));
|
|
throw createAuthError('Account not configured for this user. Please contact support.', 'ACCOUNT_REQUIRED');
|
|
}
|
|
if (!refreshedUser.account.plan) {
|
|
const logoutReasonData = {
|
|
code: 'PLAN_REQUIRED',
|
|
message: 'Active subscription required. Visit igny8.com/pricing to subscribe.',
|
|
path: typeof window !== 'undefined' ? window.location.pathname : 'unknown',
|
|
context: {
|
|
user_email: refreshedUser.email,
|
|
account_name: refreshedUser.account.name,
|
|
account_id: refreshedUser.account.id
|
|
},
|
|
timestamp: new Date().toISOString(),
|
|
source: 'refresh_user_validation'
|
|
};
|
|
console.error('🚨 LOGOUT TRIGGERED - Plan Required:', logoutReasonData);
|
|
localStorage.setItem('logout_reason', JSON.stringify(logoutReasonData));
|
|
throw createAuthError('Active subscription required. Visit igny8.com/pricing to subscribe.', 'PLAN_REQUIRED');
|
|
}
|
|
|
|
set({ user: refreshedUser, isAuthenticated: true });
|
|
} catch (error: any) {
|
|
// Only logout on specific authentication/authorization errors
|
|
// Do NOT logout on 401/403 - fetchAPI handles token refresh automatically
|
|
// A 401 that reaches here means refresh token is invalid, which fetchAPI handles by logging out
|
|
const isAuthError = error?.code === 'ACCOUNT_REQUIRED' ||
|
|
error?.code === 'PLAN_REQUIRED' ||
|
|
(error?.message && error.message.includes('Not authenticated'));
|
|
|
|
if (isAuthError) {
|
|
// Real authentication error - logout user
|
|
console.warn('Authentication error during refresh, logging out:', error);
|
|
set({ user: null, token: null, refreshToken: null, isAuthenticated: false });
|
|
} else {
|
|
// Network/server error or 401/403 (handled by fetchAPI) - don't logout
|
|
// The caller (AppLayout) will handle it gracefully
|
|
console.debug('Non-auth error during refresh (will retry):', error);
|
|
}
|
|
throw error;
|
|
}
|
|
},
|
|
}),
|
|
{
|
|
name: 'auth-storage',
|
|
// NEVER persist loading - it should always be false on app start
|
|
partialize: (state) => ({
|
|
user: state.user,
|
|
token: state.token,
|
|
refreshToken: state.refreshToken,
|
|
isAuthenticated: state.isAuthenticated,
|
|
// Explicitly exclude loading from persistence
|
|
}),
|
|
// On rehydration, ensure loading is always false
|
|
onRehydrateStorage: () => {
|
|
return (state, error) => {
|
|
// Always reset loading to false on rehydration - critical fix for stuck loading state
|
|
if (state) {
|
|
state.loading = false;
|
|
}
|
|
if (error) {
|
|
console.error('Auth store rehydration error:', error);
|
|
// If rehydration fails, ensure loading is false
|
|
if (state) {
|
|
state.loading = false;
|
|
}
|
|
}
|
|
};
|
|
},
|
|
}
|
|
)
|
|
);
|
|
|