import { createContext, useContext, useState, useEffect, useCallback, useMemo, useRef, type ReactNode, } from "react"; import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api"; const API_BASE = "/api/admin"; interface User { id: number; username: string; email: string; fullName: string; roleDisplay: string; isAdmin: boolean; totpEnabled: boolean; require2FA: boolean; permissions: string[]; [key: string]: unknown; } interface AuthState { user: User | null; loading: boolean; error: string | null; isAuthenticated: boolean; isAdmin: boolean; permissions: string[]; hasPermission: (permission: string) => boolean; } interface AuthActions { login: ( username: string, password: string, remember?: boolean, ) => Promise<{ success: boolean; requires2FA?: boolean; loginToken?: string; error?: string; remember?: boolean; }>; verify2FA: ( loginToken: string, code: string, remember?: boolean, isBackup?: boolean, ) => Promise<{ success: boolean; error?: string }>; logout: () => Promise; checkSession: () => Promise; getAccessToken: () => string | null; apiRequest: (endpoint: string, options?: RequestInit) => Promise; silentRefresh: () => Promise; updateUser: (updates: Partial) => void; } const AuthStateContext = createContext(null); const AuthActionsContext = createContext(null); function mapUser(u: Record | null): User | null { if (!u) return null; const id = (u.userId ?? u.id) as number; const firstName = (u.firstName ?? u.first_name ?? "") as string; const lastName = (u.lastName ?? u.last_name ?? "") as string; const roleName = (u.roleName ?? u.role_name ?? "") as string; return { ...u, id, fullName: (u.fullName ?? u.full_name ?? `${firstName} ${lastName}`.trim()) as string, roleDisplay: (u.roleDisplay ?? u.role_display ?? roleName) as string, isAdmin: (u.isAdmin ?? u.is_admin ?? roleName === "admin") as boolean, totpEnabled: (u.totpEnabled ?? u.totp_enabled ?? false) as boolean, require2FA: (u.require2FA ?? u.require_2fa ?? false) as boolean, permissions: (u.permissions ?? []) as string[], } as User; } export function AuthProvider({ children }: { children: ReactNode }) { const accessTokenRef = useRef(null); const tokenExpiresAtRef = useRef(null); const cachedUserRef = useRef(null); const sessionFetchedRef = useRef(false); const silentRefreshInFlightRef = useRef | null>(null); const hadValidSessionRef = useRef(false); const [user, setUser] = useState(cachedUserRef.current); const [loading, setLoading] = useState(!sessionFetchedRef.current); const [error, setError] = useState(null); const refreshTimeoutRef = useRef | null>(null); const getAccessTokenFn = useCallback((): string | null => { if ( !tokenExpiresAtRef.current || Date.now() > tokenExpiresAtRef.current - 30000 ) return null; return accessTokenRef.current; }, []); const setAccessTokenFn = useCallback( (token: string | null, expiresIn?: number) => { const ttl = expiresIn ?? 900; // default 15 min matching backend config accessTokenRef.current = token; tokenExpiresAtRef.current = token ? Date.now() + ttl * 1000 : null; if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; } if (token && ttl > 60) { refreshTimeoutRef.current = setTimeout( () => silentRefresh(), (ttl - 60) * 1000, ); } }, [], ); // eslint-disable-line react-hooks/exhaustive-deps const silentRefresh = useCallback(async (): Promise => { // Deduplicate concurrent refresh calls — token rotation means only one call can succeed if (silentRefreshInFlightRef.current) return silentRefreshInFlightRef.current; const promise = (async (): Promise => { try { const response = await fetch(`${API_BASE}/refresh`, { method: "POST", credentials: "include", }); const data = await response.json(); if (data.success && data.data?.access_token) { setAccessTokenFn(data.data.access_token, data.data.expires_in); setUser(mapUser(data.data.user)); hadValidSessionRef.current = true; return true; } accessTokenRef.current = null; tokenExpiresAtRef.current = null; setUser(null); cachedUserRef.current = null; if (hadValidSessionRef.current) setSessionExpired(); return false; } catch { // Network error — don't kick the user out, just return false return false; } finally { silentRefreshInFlightRef.current = null; } })(); silentRefreshInFlightRef.current = promise; return promise; }, [setAccessTokenFn]); const checkSession = useCallback(async (): Promise => { try { const token = getAccessTokenFn(); if (token) { const headers: Record = { "Content-Type": "application/json", Authorization: `Bearer ${token}`, }; const response = await fetch(`${API_BASE}/session`, { method: "GET", credentials: "include", headers, }); if (response.status === 429 || response.status >= 500) return !!cachedUserRef.current; const data = await response.json(); if (data.success && data.data?.user) { if (data.data.access_token) setAccessTokenFn(data.data.access_token); setUser(mapUser(data.data.user)); cachedUserRef.current = mapUser(data.data.user); hadValidSessionRef.current = true; return true; } } // No token or session invalid — try silent refresh via cookie const refreshed = await silentRefresh(); if (refreshed) return true; setUser(null); cachedUserRef.current = null; accessTokenRef.current = null; tokenExpiresAtRef.current = null; return false; } catch { return !!cachedUserRef.current; } finally { setLoading(false); sessionFetchedRef.current = true; } }, [getAccessTokenFn, setAccessTokenFn, silentRefresh]); useEffect(() => { setTokenGetter(getAccessTokenFn); setRefreshFn(silentRefresh); }, [getAccessTokenFn, silentRefresh]); useEffect(() => { checkSession(); return () => { if (refreshTimeoutRef.current) clearTimeout(refreshTimeoutRef.current); }; }, [checkSession]); const login = useCallback( async (username: string, password: string, remember = false) => { setError(null); try { const response = await fetch(`${API_BASE}/login`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ username, password, remember_me: remember }), }); const data = await response.json(); if (data.success) { if (data.data?.totp_required) { return { success: false, requires2FA: true, loginToken: data.data.login_token, remember, }; } setAccessTokenFn(data.data.access_token, data.data.expires_in); setUser(mapUser(data.data.user)); cachedUserRef.current = mapUser(data.data.user); sessionFetchedRef.current = true; hadValidSessionRef.current = true; return { success: true }; } setError(data.error); return { success: false, error: data.error }; } catch { const errorMsg = "Chyba pripojeni. Zkontrolujte prosim pripojeni k internetu a zkuste to znovu."; setError(errorMsg); return { success: false, error: errorMsg }; } }, [setAccessTokenFn], ); const verify2FA = useCallback( async ( loginToken: string, code: string, remember = false, isBackup = false, ) => { setError(null); try { const response = await fetch(`${API_BASE}/login/totp`, { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ login_token: loginToken, totp_code: code, remember_me: remember, isBackup, }), }); const data = await response.json(); if (data.success) { setAccessTokenFn(data.data.access_token, data.data.expires_in); setUser(mapUser(data.data.user)); cachedUserRef.current = mapUser(data.data.user); sessionFetchedRef.current = true; hadValidSessionRef.current = true; return { success: true }; } setError(data.error); return { success: false, error: data.error }; } catch { const errorMsg = "Chyba pripojeni."; setError(errorMsg); return { success: false, error: errorMsg }; } }, [setAccessTokenFn], ); const logout = useCallback(async () => { try { const token = getAccessTokenFn(); await fetch(`${API_BASE}/logout`, { method: "POST", headers: { ...(token && { Authorization: `Bearer ${token}` }) }, credentials: "include", }); } catch { /* ignore */ } finally { accessTokenRef.current = null; tokenExpiresAtRef.current = null; setUser(null); cachedUserRef.current = null; sessionFetchedRef.current = false; hadValidSessionRef.current = false; if (refreshTimeoutRef.current) { clearTimeout(refreshTimeoutRef.current); refreshTimeoutRef.current = null; } } }, [getAccessTokenFn]); const apiRequest = useCallback( async (endpoint: string, options: RequestInit = {}) => { let token = getAccessTokenFn(); if (!token && user) { const refreshed = await silentRefresh(); if (refreshed) token = getAccessTokenFn(); } const headers: Record = { "Content-Type": "application/json", ...(options.headers as Record), }; if (token) headers["Authorization"] = `Bearer ${token}`; const response = await fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: "include", }); if (response.status === 401 && user) { const refreshed = await silentRefresh(); if (refreshed) { token = getAccessTokenFn(); if (token) headers["Authorization"] = `Bearer ${token}`; return fetch(`${API_BASE}${endpoint}`, { ...options, headers, credentials: "include", }); } } return response; }, [getAccessTokenFn, silentRefresh, user], ); const updateUser = useCallback((updates: Partial) => { setUser((prev) => (prev ? { ...prev, ...updates } : null)); }, []); const hasPermission = useCallback( (permission: string): boolean => { if (!user) return false; if (user.isAdmin) return true; return (user.permissions || []).includes(permission); }, [user], ); const permissions = useMemo(() => user?.permissions || [], [user]); const stateValue = useMemo( () => ({ user, loading, error, isAuthenticated: !!user, isAdmin: user?.isAdmin || false, permissions, hasPermission, }), [user, loading, error, permissions, hasPermission], ); const actionsValue = useMemo( () => ({ login, verify2FA, logout, checkSession, getAccessToken: getAccessTokenFn, apiRequest, silentRefresh, updateUser, }), [ login, verify2FA, logout, checkSession, getAccessTokenFn, apiRequest, silentRefresh, updateUser, ], ); return ( {children} ); } export function useAuth(): AuthState & AuthActions { const state = useContext(AuthStateContext); const actions = useContext(AuthActionsContext); if (!state || !actions) throw new Error("useAuth must be used within an AuthProvider"); return { ...state, ...actions }; } export function useAuthState(): AuthState { const context = useContext(AuthStateContext); if (!context) throw new Error("useAuthState must be used within an AuthProvider"); return context; } export function useAuthActions(): AuthActions { const context = useContext(AuthActionsContext); if (!context) throw new Error("useAuthActions must be used within an AuthProvider"); return context; } export default AuthStateContext;