Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 0 additions & 10 deletions client/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 0 additions & 1 deletion client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@
"i18next": "^23.12.2",
"i18next-browser-languagedetector": "^7.2.1",
"idb": "^8.0.2",
"jwt-decode": "^4.0.0",
"npm-check-updates": "^17.1.11",
"react": "^18.0.0",
"react-datepicker": "^7.5.0",
Expand Down
147 changes: 45 additions & 102 deletions client/src/API.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
ILP,
IUser
} from "./type";
import { jwtDecode } from "jwt-decode";

const baseUrl: string = process.env.REACT_APP_API_BASE_URL || "http://localhost:4000";
const BATCH_SIZE = 5;
Expand Down Expand Up @@ -42,108 +41,51 @@ axiosInstance.interceptors.request.use(
config.url?.includes(route) && config.method?.toLowerCase() === 'get'
);

// Skip authentication for public GET routes
if (isPublicRoute) {
config.headers = {
...config.headers,
"Content-Type": "application/json"
};
return config;
}

let token = localStorage.getItem("token");
const refreshToken = localStorage.getItem('refreshToken');

// If no refreshToken (e.g. guest), attach the token as-is and skip refresh
if (!refreshToken) {
if (token) {
config.headers = {
...config.headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}`
};
}
return config;
}

if (token) {
config.headers = {
...config.headers,
"Content-Type": "application/json",
Authorization: `Bearer ${token}` // Attach token to Authorization header
};

const { exp } = jwtDecode(token);
const now = Date.now() / 1000;

if (!exp) {
console.error("Token has no expiration date!");
return config;
}

if (exp - now < 60) {
const response = await axios.post(baseUrl + '/refresh-token', {
refreshToken: localStorage.getItem('refreshToken'),
});
// Update only the access token, do not update refresh token
token = response.data.accessToken;
localStorage.setItem('token', token!);
config.headers.Authorization = `Bearer ${token}`;
config.headers = { ...config.headers, "Content-Type": "application/json" };

// Skip proactive refresh for public routes or guests (no refresh token cookie)
const userStr = localStorage.getItem('user');
const user = userStr ? JSON.parse(userStr) : null;
const isGuest = user?.role === 'guest';

if (!isPublicRoute && !isGuest) {
const tokenExpiresAt = localStorage.getItem('tokenExpiresAt');
if (tokenExpiresAt) {
const expiresAt = parseInt(tokenExpiresAt, 10);
const now = Date.now() / 1000;
if (expiresAt - now < 60) {
try {
// Refresh access token — server reads refreshToken cookie and sets new token cookie
const response = await axios.post(baseUrl + '/refresh-token', {}, { withCredentials: true });
if (response.data.tokenExpiresAt) {
localStorage.setItem('tokenExpiresAt', response.data.tokenExpiresAt.toString());
}
} catch (e) {
// Refresh failed — let the request continue; the server will 401 if needed
console.warn('Proactive token refresh failed:', e);
}
}
}
}

if (token) {
config.headers!.Authorization = `Bearer ${token}`;
}

return config;
},
(error) => {
// Handle request error
console.error("Request error:", error);
return Promise.reject(error);
}
);

let lastLogTime = 0;

axiosInstance.interceptors.response.use(
(response: AxiosResponse) => {
// Just return the response if everything goes well
return response;
},
(response: AxiosResponse) => response,
(error) => {
// Handle unauthorized errors (token expired)
if (error.response?.status === 401) {
console.warn("Unauthorized! Token may be expired.");

// Clear auth data if unauthorized
const token = localStorage.getItem('token');
if (token) {
try {
const { exp } = jwtDecode(token);
const now = Date.now() / 1000;
if (!exp || exp < now) {
// Clear all auth data if token is expired
localStorage.removeItem('token');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');

// Force page reload to update auth state
window.location.href = '/';
return Promise.reject(new Error('Session expired. Please log in again.'));
}
} catch (e) {
console.error("Error decoding token:", e);
}
}
}

const now = Date.now();
if (now - lastLogTime > 10000) { // Check if 10 seconds have passed since the last log
lastLogTime = now; // Update the last log time
console.warn("Unauthorized! Session may have expired.");
localStorage.removeItem('user');
localStorage.removeItem('tokenExpiresAt');
window.location.href = '/';
return Promise.reject(new Error('Session expired. Please log in again.'));
}

return Promise.reject(error);
}
);
Expand Down Expand Up @@ -178,12 +120,13 @@ export const getBooks = async (params?: any): Promise<AxiosResponse<ApiBookDataT
baseUrl + "/books", {
params: {
page: params?.page ?? 1, // API expects 1-based index
pageSize: params?.pageSize ?? 10_000,
pageSize: params?.pageSize ?? 100,
search: params?.search ?? "",
sorting: params?.sorting ?? [{ id: "title", desc: false }],
filterUsers: params?.activeUsers,
filters: params?.filters ?? []
}
},
signal: params?.signal
});
return books
} catch (error: any) {
Expand All @@ -197,7 +140,7 @@ export const getBooksByIds = async (params?: any): Promise<AxiosResponse<ApiBook
baseUrl + "/books-by-ids", {
params: {
page: params?.page ?? 1, // API expects 1-based index
pageSize: params?.pageSize ?? 10_000,
pageSize: params?.pageSize ?? 100,
search: params?.search ?? "",
sorting: params?.sorting ?? [{ id: "title", desc: false }],
ids: params?.ids ?? [],
Expand Down Expand Up @@ -283,8 +226,8 @@ export const deleteBook = async (
_id: string
): Promise<AxiosResponse<ApiBookDataType>> => {
try {
const deletedBook: AxiosResponse<ApiBookDataType> = await axiosInstance.post(
`${baseUrl}/delete-book/${_id}`
const deletedBook: AxiosResponse<ApiBookDataType> = await axiosInstance.delete(
`${baseUrl}/book/${_id}`
)
return deletedBook
} catch (error: any) {
Expand Down Expand Up @@ -317,7 +260,7 @@ export const getAutors = async (params?: any): Promise<AxiosResponse<ApiAutorDat
baseUrl + "/autors", {
params: {
page: params?.page ?? 1, // API expects 1-based index
pageSize: params?.pageSize ?? 10_000,
pageSize: params?.pageSize ?? 100,
search: params?.search ?? "",
sorting: params?.sorting ?? [{ id: "lastName", desc: false }],
filterUsers: params?.activeUsers,
Expand Down Expand Up @@ -579,8 +522,8 @@ export const login = async (
}
}

export const logout = async (refreshToken: string): Promise<AxiosResponse> => {
return axiosInstance.post(baseUrl + "/logout", { refreshToken });
export const logout = async (): Promise<AxiosResponse> => {
return axiosInstance.post(baseUrl + "/logout");
};

export const loginGuest = async (): Promise<AxiosResponse<ApiUserDataType>> => {
Expand All @@ -594,7 +537,7 @@ export const getLPs = async (params?: any): Promise<AxiosResponse<ApiLPDataType>
baseUrl + "/lps", {
params: {
page: params?.page ?? 1,
pageSize: params?.pageSize ?? 10_000,
pageSize: params?.pageSize ?? 100,
search: params?.search ?? "",
sorting: params?.sorting ?? { id: "lastName", desc: false }
}
Expand Down Expand Up @@ -751,12 +694,12 @@ export const getOldestBooks = async (): Promise<AxiosResponse> => {
}
}

export const getNewestBooks = async (): Promise<AxiosResponse> => {
export const getRecentlyUpdatedBooks = async (): Promise<AxiosResponse> => {
try {
const newestBooks: AxiosResponse = await axiosInstance.get(
`${baseUrl}/get-newest-books`,
const recentlyUpdatedBooks: AxiosResponse = await axiosInstance.get(
`${baseUrl}/get-recently-updated-books`,
)
return newestBooks;
return recentlyUpdatedBooks;
} catch (error: any) {
throw new Error(error)
}
Expand All @@ -783,7 +726,7 @@ export const getBoardGames = async (params?: any): Promise<AxiosResponse<any>> =
baseUrl + "/boardgames", {
params: {
page: params?.page ?? 1, // API expects 1-based index
pageSize: params?.pageSize ?? 10_000,
pageSize: params?.pageSize ?? 100,
search: params?.search ?? "",
sorting: params?.sorting ?? [{ id: "title", desc: false }],
filters: params?.filters ?? []
Expand Down
17 changes: 10 additions & 7 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ import { AuthProvider } from "@utils/context";
import { ModalProvider } from "@utils/context/ModalContext";
import Layout from "./Layout";
import { Outlet } from "react-router-dom";
import { ErrorBoundary } from "@components/ErrorBoundary";

const App: React.FC = () => {
return (
<AuthProvider>
<ModalProvider>
<Layout>
<Outlet />
</Layout>
</ModalProvider>
</AuthProvider>
<ErrorBoundary>
<AuthProvider>
<ModalProvider>
<Layout>
<Outlet />
</Layout>
</ModalProvider>
</AuthProvider>
</ErrorBoundary>
)
};

Expand Down
33 changes: 24 additions & 9 deletions client/src/components/ConfirmDialog.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import React, { FC, useCallback } from "react";
import { Modal } from "./Modal";
import { createRoot } from "react-dom/client";
import { useTranslation } from "react-i18next";
import { useTranslation, I18nextProvider } from "react-i18next";
import i18n from "../i18n";

interface ConfirmDialogProps {
text: string;
Expand Down Expand Up @@ -65,6 +66,8 @@ const ConfirmDialog: FC<ConfirmDialogProps> = React.memo(({
const DialogManager = (() => {
let rootElement: HTMLDivElement | null = null;
let rootInstance: ReturnType<typeof createRoot> | null = null;
let isShowing = false;
let queue: ConfirmDialogProps[] = [];

const getRoot = () => {
if (!rootElement) {
Expand All @@ -76,14 +79,19 @@ const DialogManager = (() => {
return rootInstance;
};

return {
show: (props: ConfirmDialogProps) => {
const root = getRoot();
const handleClose = () => {
root?.render(null);
};
const showNext = () => {
if (queue.length === 0) {
isShowing = false;
getRoot()?.render(null);
return;
}
isShowing = true;
const props = queue.shift()!;
const root = getRoot();
const handleClose = () => showNext();

root?.render(
root?.render(
<I18nextProvider i18n={i18n}>
<ConfirmDialog
{...props}
onOk={() => {
Expand All @@ -95,7 +103,14 @@ const DialogManager = (() => {
handleClose();
}}
/>
);
</I18nextProvider>
);
};

return {
show: (props: ConfirmDialogProps) => {
queue.push(props);
if (!isShowing) showNext();
}
};
})();
Expand Down
Loading