Commit 24bab604 authored by BangNSK's avatar BangNSK

fix: use cookie auth and stream export downloads

parent b465708d
......@@ -7,41 +7,36 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [loading, setLoading] = useState(true);
useEffect(() => {
const handleAuthExpired = () => setUser(null);
window.addEventListener('auth:expired', handleAuthExpired);
const initAuth = async () => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
try {
const response = await api.get('/auth/me');
if (response.data.success) {
setUser(response.data.data);
}
} catch (error) {
console.error('Failed to fetch user data', error);
// Token might be expired, api interceptor should handle refresh.
// If both fail, it will redirect/logout anyway.
try {
const response = await api.get('/auth/me');
if (response.data.success) {
setUser(response.data.data);
}
} catch {
setUser(null);
} finally {
setLoading(false);
}
setLoading(false);
};
initAuth();
void initAuth();
return () => window.removeEventListener('auth:expired', handleAuthExpired);
}, []);
const login = async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password });
if (response.data.success) {
const { accessToken, refreshToken, user } = response.data.data;
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
setUser(response.data.data.user);
}
};
const logout = async () => {
try {
const refreshToken = localStorage.getItem('refreshToken');
if (refreshToken) {
await api.post('/auth/logout', { refreshToken });
}
await api.post('/auth/logout', {});
} catch (error) {
console.error('Logout error', error);
} finally {
......@@ -55,16 +50,34 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
await api.post('/auth/register', { email, password, fullName: fullName?.trim() || undefined });
};
const updateProfile = async (data: { fullName?: string; oldPassword?: string; password?: string }) => {
const updateProfile = async (data: { fullName?: string }) => {
const response = await api.put('/auth/me', data);
if (response.data.success) setUser(response.data.data);
};
const changePassword = async (data: {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}) => {
await api.post('/auth/change-password', data);
};
const isAdmin = user?.role === 'ADMIN';
const isCrawler = user?.role === 'ADMIN' || user?.role === 'CRAWLER_USER';
return (
<AuthContext.Provider value={{ user, loading, login, logout, register, updateProfile, isAdmin, isCrawler }}>
<AuthContext.Provider value={{
user,
loading,
login,
logout,
register,
updateProfile,
changePassword,
isAdmin,
isCrawler,
}}>
{children}
</AuthContext.Provider>
);
......
......@@ -17,7 +17,12 @@ export interface AuthContextType {
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string, fullName?: string) => Promise<void>;
updateProfile: (data: { fullName?: string; oldPassword?: string; password?: string }) => Promise<void>;
updateProfile: (data: { fullName?: string }) => Promise<void>;
changePassword: (data: {
currentPassword: string;
newPassword: string;
confirmPassword: string;
}) => Promise<void>;
isAdmin: boolean;
isCrawler: boolean;
}
......
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { api } from '../services/api';
import { api, getApiUrl } from '../services/api';
import { useAuth } from '../context/auth';
import {
ArrowLeft,
......@@ -287,34 +287,6 @@ export const JobDetail: React.FC = () => {
}
};
const handleDownload = async (exportId: string, fileName: string, _mimeType: string) => {
try {
const res = await api.get(`/exports/${exportId}/download`, { responseType: 'blob' });
const blob = res.data;
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.setAttribute('download', fileName);
document.body.appendChild(link);
link.click();
link.remove();
window.URL.revokeObjectURL(url);
} catch (e: any) {
console.error('Failed to download export file:', e);
if (e.response?.data instanceof Blob) {
const text = await e.response.data.text();
try {
const json = JSON.parse(text);
alert(json.message || 'Tải file thất bại.');
} catch {
alert('Tải file thất bại.');
}
} else {
alert(e.response?.data?.message || 'Tải file thất bại.');
}
}
};
const copyToClipboard = (text: string) => {
void navigator.clipboard.writeText(text);
setCopied(true);
......@@ -547,13 +519,16 @@ export const JobDetail: React.FC = () => {
</div>
</div>
{exp.status === 'COMPLETED' ? (
<button
onClick={() => handleDownload(exp.id, exp.fileName, exp.mimeType || 'application/octet-stream')}
<a
href={getApiUrl(`/exports/${encodeURIComponent(exp.id)}/download`)}
download={exp.fileName}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-emerald-50 text-emerald-700 hover:bg-emerald-100 text-xs font-semibold transition-colors border border-emerald-100"
>
<Download className="h-3.5 w-3.5" />
Tải xuống
</button>
</a>
) : (
<span className="text-xs text-slate-400 capitalize">{exp.status.toLowerCase()}</span>
)}
......
......@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { Shield, Mail, UserCheck, Loader2, Save, KeyRound } from 'lucide-react';
export const Profile: React.FC = () => {
const { user, updateProfile } = useAuth();
const { user, updateProfile, changePassword } = useAuth();
const [fullName, setFullName] = useState(user?.fullName || '');
const [oldPassword, setOldPassword] = useState('');
const [password, setPassword] = useState('');
......@@ -20,7 +20,14 @@ export const Profile: React.FC = () => {
if (password && password !== confirmPassword) return setError('Mật khẩu xác nhận không khớp.');
setSubmitting(true);
try {
await updateProfile({ fullName: fullName.trim(), ...(password ? { oldPassword, password } : {}) });
if (password) {
await changePassword({
currentPassword: oldPassword,
newPassword: password,
confirmPassword,
});
}
await updateProfile({ fullName: fullName.trim() });
setOldPassword(''); setPassword(''); setConfirmPassword('');
setMessage('Cập nhật hồ sơ thành công.');
} catch (err: any) {
......
import axios from 'axios';
const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://171.247.68.96:4011/api/v1')
export const API_BASE_URL = (import.meta.env.VITE_API_BASE_URL || 'http://171.247.68.96:4011/api/v1')
.replace(/\/$/, '');
export const api = axios.create({
baseURL: API_BASE_URL,
withCredentials: true,
headers: {
'Content-Type': 'application/json',
},
});
// Flag to prevent multiple concurrent token refresh requests
let isRefreshing = false;
let failedQueue: Array<{
resolve: (value: unknown) => void;
resolve: (value?: unknown) => void;
reject: (error: unknown) => void;
}> = [];
const processQueue = (error: any, token: string | null = null) => {
const processQueue = (error?: unknown) => {
failedQueue.forEach((prom) => {
if (error) {
prom.reject(error);
} else {
prom.resolve(token);
prom.resolve();
}
});
failedQueue = [];
};
// Request Interceptor: Attach access token
api.interceptors.request.use(
(config) => {
const token = localStorage.getItem('accessToken');
if (token && config.headers) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response Interceptor: Handle auto token refresh on 401
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
// Avoid infinite loop if auth requests fail (like /auth/login or /auth/refresh itself)
if (originalRequest.url?.includes('/auth/login') || originalRequest.url?.includes('/auth/refresh')) {
if (
originalRequest.url?.includes('/auth/login') ||
originalRequest.url?.includes('/auth/refresh') ||
originalRequest.url?.includes('/auth/logout')
) {
return Promise.reject(error);
}
if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) {
// Queue the request until token is refreshed
return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject });
})
.then((token) => {
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.then(() => api(originalRequest))
.catch((err) => Promise.reject(err));
}
originalRequest._retry = true;
isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
processQueue(error, null);
isRefreshing = false;
handleLogout();
return Promise.reject(error);
}
try {
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, {
refreshToken,
});
const { accessToken: newAccessToken, refreshToken: newRefreshToken } = response.data.data || response.data;
localStorage.setItem('accessToken', newAccessToken);
if (newRefreshToken) {
localStorage.setItem('refreshToken', newRefreshToken);
}
api.defaults.headers.common['Authorization'] = `Bearer ${newAccessToken}`;
originalRequest.headers.Authorization = `Bearer ${newAccessToken}`;
processQueue(null, newAccessToken);
isRefreshing = false;
await api.post('/auth/refresh', {});
processQueue();
return api(originalRequest);
} catch (refreshError) {
processQueue(refreshError, null);
isRefreshing = false;
handleLogout();
processQueue(refreshError);
notifyAuthExpired();
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
}
......@@ -106,11 +70,12 @@ api.interceptors.response.use(
}
);
function handleLogout() {
export const getApiUrl = (path: string) =>
`${API_BASE_URL}/${path.replace(/^\/+/, '')}`;
function notifyAuthExpired() {
localStorage.removeItem('accessToken');
localStorage.removeItem('refreshToken');
localStorage.removeItem('user');
if (window.location.pathname !== '/login') {
window.location.href = '/login';
}
window.dispatchEvent(new Event('auth:expired'));
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment