Added hadValidSessionRef to track whether the user was ever authenticated during this page load. setSessionExpired() in silentRefresh now only fires when the ref is true, preventing the alert on direct visits by unauthenticated users. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
435 lines
13 KiB
TypeScript
435 lines
13 KiB
TypeScript
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<void>;
|
|
checkSession: () => Promise<boolean>;
|
|
getAccessToken: () => string | null;
|
|
apiRequest: (endpoint: string, options?: RequestInit) => Promise<Response>;
|
|
silentRefresh: () => Promise<boolean>;
|
|
updateUser: (updates: Partial<User>) => void;
|
|
}
|
|
|
|
const AuthStateContext = createContext<AuthState | null>(null);
|
|
const AuthActionsContext = createContext<AuthActions | null>(null);
|
|
|
|
function mapUser(u: Record<string, unknown> | 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<string | null>(null);
|
|
const tokenExpiresAtRef = useRef<number | null>(null);
|
|
const cachedUserRef = useRef<User | null>(null);
|
|
const sessionFetchedRef = useRef(false);
|
|
const silentRefreshInFlightRef = useRef<Promise<boolean> | null>(null);
|
|
const hadValidSessionRef = useRef(false);
|
|
const [user, setUser] = useState<User | null>(cachedUserRef.current);
|
|
const [loading, setLoading] = useState(!sessionFetchedRef.current);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const refreshTimeoutRef = useRef<ReturnType<typeof setTimeout> | 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<boolean> => {
|
|
// Deduplicate concurrent refresh calls — token rotation means only one call can succeed
|
|
if (silentRefreshInFlightRef.current)
|
|
return silentRefreshInFlightRef.current;
|
|
|
|
const promise = (async (): Promise<boolean> => {
|
|
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<boolean> => {
|
|
try {
|
|
const token = getAccessTokenFn();
|
|
if (token) {
|
|
const headers: Record<string, string> = {
|
|
"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<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(options.headers as Record<string, string>),
|
|
};
|
|
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<User>) => {
|
|
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<AuthState>(
|
|
() => ({
|
|
user,
|
|
loading,
|
|
error,
|
|
isAuthenticated: !!user,
|
|
isAdmin: user?.isAdmin || false,
|
|
permissions,
|
|
hasPermission,
|
|
}),
|
|
[user, loading, error, permissions, hasPermission],
|
|
);
|
|
|
|
const actionsValue = useMemo<AuthActions>(
|
|
() => ({
|
|
login,
|
|
verify2FA,
|
|
logout,
|
|
checkSession,
|
|
getAccessToken: getAccessTokenFn,
|
|
apiRequest,
|
|
silentRefresh,
|
|
updateUser,
|
|
}),
|
|
[
|
|
login,
|
|
verify2FA,
|
|
logout,
|
|
checkSession,
|
|
getAccessTokenFn,
|
|
apiRequest,
|
|
silentRefresh,
|
|
updateUser,
|
|
],
|
|
);
|
|
|
|
return (
|
|
<AuthActionsContext.Provider value={actionsValue}>
|
|
<AuthStateContext.Provider value={stateValue}>
|
|
{children}
|
|
</AuthStateContext.Provider>
|
|
</AuthActionsContext.Provider>
|
|
);
|
|
}
|
|
|
|
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;
|