Two dashboard bugs reported after clock-in: 1. 'Dochazka dnes' / 'Pritomni dnes' showed everyone absent even after refresh: attendance.shift_date is @db.Date and Prisma truncates filter Dates to their UTC date part, so the local-midnight boundaries (new Date(y,m,d) = 22:00/23:00Z of the previous day) queried [yesterday, today) and matched zero of today's punches. Boundaries are now UTC-midnight instants of the local (Prague) calendar day. Reproduced empirically against real rows; route-level regression test added (dashboard.test.ts). 2. After logout + login as a different user, cached dashboards/buttons from the previous user were served: query keys are user-agnostic and the React Query cache outlives the session. logout(), login() and the 2FA verify path now clear the whole cache. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
470 lines
15 KiB
TypeScript
470 lines
15 KiB
TypeScript
import {
|
|
createContext,
|
|
useContext,
|
|
useState,
|
|
useEffect,
|
|
useCallback,
|
|
useMemo,
|
|
useRef,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { setSessionExpired, setTokenGetter, setRefreshFn } from "../utils/api";
|
|
import { queryClient } from "../lib/queryClient";
|
|
|
|
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,
|
|
);
|
|
}
|
|
},
|
|
[],
|
|
);
|
|
|
|
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,
|
|
};
|
|
}
|
|
// Query keys are user-agnostic (["dashboard"], ["attendance"], …),
|
|
// so anything cached from a previously logged-in user would be
|
|
// served to this one — drop the whole cache on every login.
|
|
queryClient.clear();
|
|
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 {
|
|
// Backup codes have their own endpoint + schema (8-char codes would
|
|
// fail /login/totp's strict 6-digit TotpVerifySchema). Both endpoints
|
|
// complete the same login flow: tokens issued + refresh cookie set.
|
|
const response = await fetch(
|
|
isBackup
|
|
? `${API_BASE}/totp/backup-verify`
|
|
: `${API_BASE}/login/totp`,
|
|
{
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
credentials: "include",
|
|
body: JSON.stringify(
|
|
isBackup
|
|
? {
|
|
login_token: loginToken,
|
|
backup_code: code,
|
|
remember_me: remember,
|
|
}
|
|
: {
|
|
login_token: loginToken,
|
|
totp_code: code,
|
|
remember_me: remember,
|
|
},
|
|
),
|
|
},
|
|
);
|
|
const data = await response.json();
|
|
if (data.success) {
|
|
// Same reason as in login(): the cache may hold the previous
|
|
// user's data under user-agnostic keys.
|
|
queryClient.clear();
|
|
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;
|
|
}
|
|
// The React Query cache outlives the session — without this, the next
|
|
// user to log in on this browser sees the previous user's cached
|
|
// dashboards/lists until each query refetches.
|
|
queryClient.clear();
|
|
}
|
|
}, [getAccessTokenFn]);
|
|
|
|
// NOTE: this is a SECOND 401-refresh-and-retry path that parallels
|
|
// `apiFetch` in src/admin/utils/api.ts (which most pages use via the query
|
|
// helpers). The two diverge: `apiRequest` ignores AbortSignals and does NOT
|
|
// set the session-expired flag, whereas `apiFetch` dedupes concurrent
|
|
// refreshes, honors abort, and surfaces the 499 session-expired sentinel.
|
|
// Consolidating onto `apiFetch` is a tracked follow-up but is deferred here
|
|
// because it touches the core auth/refresh flow (high regression risk). If
|
|
// you change refresh/retry semantics in one, mirror it in the other.
|
|
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;
|