- Added `useUserStatus` hook to manage user status fetching and state. - Integrated user status in Quick Action Dashboard and related pages. - Enhanced error handling and loading states for user status. - Updated profile completion and document upload flows to refresh user status after actions. - Created a centralized API utility for handling requests and responses. - Refactored authentication token management to use session storage.
237 lines
7.7 KiB
TypeScript
237 lines
7.7 KiB
TypeScript
import { create } from "zustand";
|
|
import { log } from "../utils/logger";
|
|
|
|
// Helper to decode JWT and get expiry
|
|
function getTokenExpiry(token: string | null): Date | null {
|
|
if (!token) return null;
|
|
try {
|
|
const [, payload] = token.split(".");
|
|
const { exp } = JSON.parse(atob(payload));
|
|
return exp ? new Date(exp * 1000) : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// Helper functions for sessionStorage with SSR safety
|
|
const getStoredUser = () => {
|
|
if (typeof window === 'undefined') return null; // SSR check
|
|
try {
|
|
const userData = sessionStorage.getItem('user');
|
|
const parsed = userData ? JSON.parse(userData) : null;
|
|
log("👤 Retrieved user from sessionStorage:", parsed ? `${parsed.email || parsed.companyName}` : null);
|
|
return parsed;
|
|
} catch (error) {
|
|
log("❌ Error retrieving user from sessionStorage:", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
const getStoredToken = () => {
|
|
if (typeof window === 'undefined') return null; // SSR check
|
|
try {
|
|
const token = sessionStorage.getItem('accessToken');
|
|
if (token) {
|
|
const expiry = getTokenExpiry(token);
|
|
if (expiry && expiry.getTime() > Date.now()) {
|
|
log("🔑 Retrieved valid token from sessionStorage");
|
|
return token;
|
|
} else {
|
|
log("⏰ Stored token expired, removing");
|
|
sessionStorage.removeItem('accessToken');
|
|
return null;
|
|
}
|
|
}
|
|
return null;
|
|
} catch (error) {
|
|
log("❌ Error retrieving token from sessionStorage:", error);
|
|
return null;
|
|
}
|
|
};
|
|
|
|
interface User {
|
|
email?: string;
|
|
companyName?: string;
|
|
[key: string]: any;
|
|
}
|
|
|
|
interface AuthStore {
|
|
accessToken: string | null;
|
|
user: User | null;
|
|
isAuthReady: boolean;
|
|
isRefreshing: boolean;
|
|
refreshPromise: Promise<boolean> | null;
|
|
setAuthReady: (ready: boolean) => void;
|
|
setAccessToken: (token: string | null) => void;
|
|
setUser: (userData: User | null) => void;
|
|
clearAuth: () => void;
|
|
logout: () => Promise<void>;
|
|
refreshAuthToken: () => Promise<boolean | null>;
|
|
getAuthState: () => AuthStore;
|
|
}
|
|
|
|
const useAuthStore = create<AuthStore>((set, get) => ({
|
|
// Initialize with SSR-safe defaults
|
|
accessToken: typeof window !== 'undefined' ? getStoredToken() : null,
|
|
user: typeof window !== 'undefined' ? getStoredUser() : null,
|
|
isAuthReady: false,
|
|
isRefreshing: false,
|
|
refreshPromise: null,
|
|
|
|
setAuthReady: (ready) => {
|
|
log("🔔 Zustand: setAuthReady ->", ready);
|
|
set({ isAuthReady: !!ready });
|
|
},
|
|
|
|
setAccessToken: (token) => {
|
|
log("🔑 Zustand: Setting access token in memory:", token ? `${token.substring(0, 20)}...` : null);
|
|
if (typeof window !== 'undefined') {
|
|
try {
|
|
if (token) {
|
|
const expiry = getTokenExpiry(token);
|
|
log("⏳ Zustand: Token expiry:", expiry ? expiry.toLocaleString() : "Unknown");
|
|
sessionStorage.setItem('accessToken', token);
|
|
log("✅ Token stored in sessionStorage");
|
|
} else {
|
|
sessionStorage.removeItem('accessToken');
|
|
log("🗑️ Token removed from sessionStorage");
|
|
}
|
|
} catch (error) {
|
|
log("❌ Error storing token in sessionStorage:", error);
|
|
}
|
|
}
|
|
set({ accessToken: token });
|
|
},
|
|
|
|
setUser: (userData) => {
|
|
log("👤 Zustand: Setting user data:", userData ? `${userData.email || userData.companyName}` : null);
|
|
if (typeof window !== 'undefined') {
|
|
try {
|
|
if (userData) {
|
|
sessionStorage.setItem('user', JSON.stringify(userData));
|
|
log("✅ User data stored in sessionStorage successfully");
|
|
} else {
|
|
sessionStorage.removeItem('user');
|
|
log("🗑️ User data removed from sessionStorage");
|
|
}
|
|
} catch (error) {
|
|
log("❌ Error storing user in sessionStorage:", error);
|
|
}
|
|
}
|
|
set({ user: userData });
|
|
},
|
|
|
|
clearAuth: () => {
|
|
log("🧹 Zustand: Clearing all auth data from memory and removing persisted user");
|
|
if (typeof window !== 'undefined') {
|
|
try {
|
|
sessionStorage.removeItem('user');
|
|
log("✅ User cleared from sessionStorage");
|
|
sessionStorage.removeItem('accessToken');
|
|
log("✅ accessToken cleared from sessionStorage");
|
|
} catch (error) {
|
|
log("❌ Error clearing user/accessToken from sessionStorage:", error);
|
|
}
|
|
}
|
|
set({ accessToken: null, user: null });
|
|
},
|
|
|
|
logout: async () => {
|
|
log("🚪 Zustand: Logging out — revoking refresh token on server");
|
|
try {
|
|
const logoutUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/logout`;
|
|
log("🌐 Zustand: Calling logout endpoint:", logoutUrl);
|
|
const res = await fetch(logoutUrl, {
|
|
method: "POST",
|
|
credentials: "include",
|
|
headers: {
|
|
"Content-Type": "application/json"
|
|
}
|
|
});
|
|
log("📡 Logout response status:", res.status);
|
|
try {
|
|
const body = await res.json().catch(() => null);
|
|
log("📦 Logout response body:", body);
|
|
} catch {}
|
|
// Attempt to clear refreshToken cookie client-side (will only work if not httpOnly)
|
|
if (typeof window !== 'undefined') {
|
|
document.cookie = "refreshToken=; path=/; expires=Thu, 01 Jan 1970 00:00:00 GMT; Secure; SameSite=Strict";
|
|
}
|
|
} catch (error) {
|
|
log("❌ Error calling logout endpoint:", error);
|
|
} finally {
|
|
get().clearAuth();
|
|
get().setAuthReady(true);
|
|
}
|
|
},
|
|
|
|
refreshAuthToken: async () => {
|
|
// If there's already a refresh in flight, return that promise
|
|
if (get().refreshPromise) {
|
|
log("🔁 Zustand: refreshAuthToken - returning existing refresh promise");
|
|
return get().refreshPromise;
|
|
}
|
|
|
|
// SHORT-CIRCUIT: if we already have a valid accessToken that's not about to expire, skip refresh
|
|
const currentToken = get().accessToken;
|
|
if (currentToken) {
|
|
const expiry = getTokenExpiry(currentToken);
|
|
if (expiry && expiry.getTime() - Date.now() > 60 * 1000) { // more than 60s left
|
|
log("⏸️ Zustand: accessToken present and valid, skipping refresh");
|
|
return Promise.resolve(true);
|
|
}
|
|
}
|
|
|
|
log("🔄 Zustand: refreshAuthToken - starting new refresh");
|
|
// create promise so concurrent callers can await it
|
|
const p = (async () => {
|
|
set({ isRefreshing: true });
|
|
try {
|
|
const refreshUrl = `${process.env.NEXT_PUBLIC_API_BASE_URL}/api/refresh`;
|
|
log("🌐 Zustand: Calling refresh endpoint:", refreshUrl);
|
|
|
|
const res = await fetch(refreshUrl, {
|
|
method: "POST",
|
|
credentials: "include"
|
|
});
|
|
|
|
log("📡 Zustand: Refresh response status:", res.status);
|
|
const body = await res.json().catch(() => null);
|
|
log("📦 Zustand: Refresh response body:", body);
|
|
|
|
if (res.ok && body && body.accessToken) {
|
|
log("✅ Zustand: Refresh succeeded, setting in-memory token and user");
|
|
get().setAccessToken(body.accessToken);
|
|
if (body.user) get().setUser(body.user);
|
|
return true;
|
|
} else {
|
|
log("❌ Zustand: Refresh failed (no accessToken or non-ok). Clearing auth state");
|
|
get().clearAuth();
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
log("❌ Zustand: Refresh error:", error);
|
|
get().clearAuth();
|
|
return false;
|
|
} finally {
|
|
set({ isRefreshing: false, refreshPromise: null });
|
|
log("🔔 Zustand: refreshAuthToken - finished (flags cleared)");
|
|
}
|
|
})();
|
|
|
|
set({ refreshPromise: p });
|
|
return p;
|
|
},
|
|
|
|
getAuthState: () => {
|
|
const state = get();
|
|
log("📊 Current auth state:", {
|
|
hasToken: !!state.accessToken,
|
|
tokenPrefix: state.accessToken ? `${state.accessToken.substring(0, 20)}...` : null,
|
|
user: state.user ? `${state.user.email || state.user.companyName}` : null
|
|
});
|
|
return state;
|
|
}
|
|
}));
|
|
|
|
export default useAuthStore; |