feat(users): add reset password functionality for users and implement forgot password page

This commit is contained in:
Tran Anh Tuan
2026-02-03 17:33:47 +07:00
parent 9bc15192ec
commit dca363275e
35 changed files with 1592 additions and 321 deletions

View File

@@ -1,43 +1,48 @@
import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { apiRefreshToken } from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd';
// Error handling scheme: Error types
// enum ErrorShowType {
// SILENT = 0,
// WARN_MESSAGE = 1,
// ERROR_MESSAGE = 2,
// NOTIFICATION = 3,
// REDIRECT = 9,
// }
// Response data structure agreed with the backend
// interface ResponseStructure<T = any> {
// success: boolean;
// data: T;
// errorCode?: number;
// errorMessage?: string;
// showType?: ErrorShowType;
// }
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null;
// const codeMessage = {
// 200: 'The server successfully returned the requested data。',
// 201: 'New or modified data succeeded。',
// 202: 'A request has been queued in the background (asynchronous task)。',
// 204: 'Data deleted successfully。',
// 400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
// 401: 'The user does not have permission (token, username, password is wrong) 。',
// 403: 'User is authorized, but access is prohibited。',
// 404: 'The request issued was for a non-existent record, the server did not operate。',
// 406: 'The requested format is not available。',
// 410: 'The requested resource is permanently deleted and will no longer be available。',
// 422: 'When creating an object, a validation error occurred。',
// 500: 'Server error, please check the server。',
// 502: 'Gateway error。',
// 503: 'Service unavailable, server temporarily overloaded or maintained。',
// 504: 'Gateway timeout。',
// };
async function getValidAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
return null;
}
if (!refreshingTokenPromise) {
refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
.then((resp) => {
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
return null;
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('Refresh token failed:', err);
return null;
})
.finally(() => {
refreshingTokenPromise = null;
});
}
return refreshingTokenPromise;
}
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
@@ -45,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
(status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -76,8 +83,8 @@ export const handleRequestConfig: RequestConfig = {
// 'Unknown error';
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
removeToken();
history.push(ROUTE_LOGIN);
// 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
return;
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
@@ -93,7 +100,7 @@ export const handleRequestConfig: RequestConfig = {
// Request interceptors
requestInterceptors: [
(url: string, options: any) => {
const token = getToken();
const token = getAccessToken();
// console.log('Token: ', token);
return {
@@ -113,14 +120,67 @@ export const handleRequestConfig: RequestConfig = {
// Unwrap data from backend response
responseInterceptors: [
(response) => {
async (response: any, options: any) => {
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
const alreadyRetried = options?.skipAuthRefresh === true;
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
!isRefreshRequest &&
!alreadyRetried
) {
const newToken = await getValidAccessToken();
console.log('Access Token hết hạn, đang refresh...');
// Không refresh được => xoá dữ liệu, điều hướng về trang login
if (!newToken) {
console.log('Access Token hết hạn và không thể refresh');
const { pathname } = history.location;
clearAllData();
if (pathname !== ROUTE_LOGIN) {
console.log(
'Request dev: Chuyển về trang login và có pathname ',
pathname,
);
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
console.log(
'Request dev: Chuyển về trang login và không có pathname',
);
history.push(ROUTE_LOGIN);
}
return Promise.reject(
new Error('Unauthorized and refresh token is invalid'),
);
}
const newOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `${newToken}`,
},
skipAuthRefresh: true,
};
// Gọi lại request gốc với accessToken mới
return request(response.url, newOptions);
}
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
(isRefreshRequest || alreadyRetried)
) {
clearAllData();
history.push(ROUTE_LOGIN);
return Promise.reject(new Error('Unauthorized'));
}
// console.log('Response from server: ', response);
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
return {
status: response.status,
statusText: response.statusText,
@@ -129,5 +189,5 @@ export const handleRequestConfig: RequestConfig = {
data: response.data,
};
},
],
] as any,
};

View File

@@ -1,26 +1,48 @@
import { HTTPSTATUS } from '@/constants';
import { API_PATH_REFRESH_TOKEN } from '@/constants/api';
import { ROUTE_LOGIN } from '@/constants/routes';
import { getToken, removeToken } from '@/utils/storage';
import { history, RequestConfig } from '@umijs/max';
import { apiRefreshToken } from '@/services/master/AuthController';
import { checkRefreshTokenExpired } from '@/utils/jwt';
import {
clearAllData,
getAccessToken,
getRefreshToken,
setAccessToken,
} from '@/utils/storage';
import { history, request, RequestConfig } from '@umijs/max';
import { message } from 'antd';
const codeMessage = {
200: 'The server successfully returned the requested data。',
201: 'New or modified data succeeded。',
202: 'A request has been queued in the background (asynchronous task)。',
204: 'Data deleted successfully。',
400: 'There is an error in the request sent, the server did not perform the operation of creating or modifying data。',
401: 'The user does not have permission (token, username, password is wrong) 。',
403: 'User is authorized, but access is prohibited。',
404: 'The request issued was for a non-existent record, the server did not operate。',
406: 'The requested format is not available。',
410: 'The requested resource is permanently deleted and will no longer be available。',
422: 'When creating an object, a validation error occurred。',
500: 'Server error, please check the server。',
502: 'Gateway error。',
503: 'Service unavailable, server temporarily overloaded or maintained。',
504: 'Gateway timeout。',
};
// Trạng thái dùng chung cho cơ chế refresh token + hàng đợi
let refreshingTokenPromise: Promise<string | null> | null = null;
async function getValidAccessToken(): Promise<string | null> {
const refreshToken = getRefreshToken();
if (!refreshToken || checkRefreshTokenExpired(refreshToken)) {
return null;
}
if (!refreshingTokenPromise) {
refreshingTokenPromise = apiRefreshToken({ refresh_token: refreshToken })
.then((resp) => {
if (resp?.access_token) {
setAccessToken(resp.access_token);
return resp.access_token;
}
return null;
})
.catch((err) => {
// eslint-disable-next-line no-console
console.error('Refresh token failed:', err);
return null;
})
.finally(() => {
refreshingTokenPromise = null;
});
}
return refreshingTokenPromise;
}
// Runtime configuration
export const handleRequestConfig: RequestConfig = {
@@ -28,7 +50,9 @@ export const handleRequestConfig: RequestConfig = {
timeout: 20000,
validateStatus: (status) => {
return (
(status >= 200 && status < 300) || status === HTTPSTATUS.HTTP_NOTFOUND
(status >= 200 && status < 300) ||
status === HTTPSTATUS.HTTP_NOTFOUND ||
status === HTTPSTATUS.HTTP_UNAUTHORIZED
);
},
headers: { 'X-Requested-With': 'XMLHttpRequest' },
@@ -48,24 +72,25 @@ export const handleRequestConfig: RequestConfig = {
// Error catching and handling
errorHandler: (error: any) => {
if (error.response) {
const { status, statusText, data } = error.response;
const { status } = error.response;
// Ưu tiên: codeMessage → backend message → statusText
const errMsg =
codeMessage[status as keyof typeof codeMessage] ||
data?.message ||
statusText ||
'Unknown error';
// const errMsg =
// codeMessage[status as keyof typeof codeMessage] ||
// data?.message ||
// statusText ||
// 'Unknown error';
message.error(`${status}: ${errMsg}`);
if (status === 401) {
removeToken();
history.push(ROUTE_LOGIN);
if (status === HTTPSTATUS.HTTP_UNAUTHORIZED) {
// 401 đã được xử lý trong responseInterceptors (refresh token + redirect nếu cần)
return;
} else if (status === HTTPSTATUS.HTTP_SERVERERROR) {
// message.error('💥 Internal server error!');
} else {
message.error(`${status}: ${error.message || 'Error'}`);
}
} else if (error.request) {
message.error('🚨 No response from server!');
} else if (status) {
// message.error('💥 Internal server error!');
} else {
message.error(`⚠️ Request setup error: ${error.message}`);
}
@@ -79,7 +104,7 @@ export const handleRequestConfig: RequestConfig = {
// Chỉ cần thêm token, proxy sẽ xử lý việc redirect đến đúng port
// URL sẽ bắt đầu với /api và proxy sẽ chuyển đến hostname:81/api
const token = getToken();
const token = getAccessToken();
return {
url: url,
options: {
@@ -96,14 +121,65 @@ export const handleRequestConfig: RequestConfig = {
],
// Unwrap data from backend response
// responseInterceptors: [
// (response) => {
// const res = response.data as ResponseStructure<any>;
// if (res && res.success) {
// // ✅ Trả ra data luôn thay vì cả object
// return res.data;
// }
// return response.data;
// },
// ],
responseInterceptors: [
async (response: any, options: any) => {
const isRefreshRequest = response.url?.includes(API_PATH_REFRESH_TOKEN);
const alreadyRetried = options?.skipAuthRefresh === true;
// Xử lý 401: đưa request vào hàng đợi, gọi refresh token, rồi gọi lại
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
// Không tự refresh cho chính API refresh token để tránh vòng lặp vô hạn
!isRefreshRequest &&
!alreadyRetried
) {
const newToken = await getValidAccessToken();
// Không refresh được => xoá dữ liệu, điều hướng về trang login
if (!newToken) {
const { pathname } = history.location;
clearAllData();
if (pathname !== ROUTE_LOGIN) {
history.push(`${ROUTE_LOGIN}?redirect=${pathname}`);
} else {
history.push(ROUTE_LOGIN);
}
return Promise.reject(
new Error('Unauthorized and refresh token is invalid'),
);
}
const newOptions = {
...options,
headers: {
...(options.headers || {}),
Authorization: `${newToken}`,
},
skipAuthRefresh: true,
};
// Gọi lại request gốc với accessToken mới
return request(response.url, newOptions);
}
if (
response.status === HTTPSTATUS.HTTP_UNAUTHORIZED &&
(isRefreshRequest || alreadyRetried)
) {
clearAllData();
history.push(ROUTE_LOGIN);
return Promise.reject(new Error('Unauthorized'));
}
// console.log('Response from server: ', response);
return {
status: response.status,
statusText: response.statusText,
headers: response.headers,
config: response.config,
data: response.data,
};
},
] as any,
};

View File

@@ -5,6 +5,14 @@ export const loginRoute = {
layout: false,
};
export const forgotPasswordRoute = {
title: 'Forgot Password',
path: '/password/reset',
component: './Auth/ForgotPassword',
layout: false,
access: 'canAll',
};
export const profileRoute = {
name: 'profile',
icon: 'icon-user',