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 ...@@ -7,41 +7,36 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
useEffect(() => { useEffect(() => {
const handleAuthExpired = () => setUser(null);
window.addEventListener('auth:expired', handleAuthExpired);
const initAuth = async () => { const initAuth = async () => {
const accessToken = localStorage.getItem('accessToken'); try {
if (accessToken) { const response = await api.get('/auth/me');
try { if (response.data.success) {
const response = await api.get('/auth/me'); setUser(response.data.data);
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.
} }
} 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 login = async (email: string, password: string) => {
const response = await api.post('/auth/login', { email, password }); const response = await api.post('/auth/login', { email, password });
if (response.data.success) { if (response.data.success) {
const { accessToken, refreshToken, user } = response.data.data; setUser(response.data.data.user);
localStorage.setItem('accessToken', accessToken);
localStorage.setItem('refreshToken', refreshToken);
setUser(user);
} }
}; };
const logout = async () => { const logout = async () => {
try { try {
const refreshToken = localStorage.getItem('refreshToken'); await api.post('/auth/logout', {});
if (refreshToken) {
await api.post('/auth/logout', { refreshToken });
}
} catch (error) { } catch (error) {
console.error('Logout error', error); console.error('Logout error', error);
} finally { } finally {
...@@ -55,16 +50,34 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children ...@@ -55,16 +50,34 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
await api.post('/auth/register', { email, password, fullName: fullName?.trim() || undefined }); 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); const response = await api.put('/auth/me', data);
if (response.data.success) setUser(response.data.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 isAdmin = user?.role === 'ADMIN';
const isCrawler = user?.role === 'ADMIN' || user?.role === 'CRAWLER_USER'; const isCrawler = user?.role === 'ADMIN' || user?.role === 'CRAWLER_USER';
return ( 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} {children}
</AuthContext.Provider> </AuthContext.Provider>
); );
......
...@@ -17,7 +17,12 @@ export interface AuthContextType { ...@@ -17,7 +17,12 @@ export interface AuthContextType {
login: (email: string, password: string) => Promise<void>; login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>; logout: () => Promise<void>;
register: (email: string, password: string, fullName?: string) => 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; isAdmin: boolean;
isCrawler: boolean; isCrawler: boolean;
} }
......
import React, { useCallback, useEffect, useRef, useState } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { api } from '../services/api'; import { api, getApiUrl } from '../services/api';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { import {
ArrowLeft, ArrowLeft,
...@@ -287,34 +287,6 @@ export const JobDetail: React.FC = () => { ...@@ -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) => { const copyToClipboard = (text: string) => {
void navigator.clipboard.writeText(text); void navigator.clipboard.writeText(text);
setCopied(true); setCopied(true);
...@@ -547,13 +519,16 @@ export const JobDetail: React.FC = () => { ...@@ -547,13 +519,16 @@ export const JobDetail: React.FC = () => {
</div> </div>
</div> </div>
{exp.status === 'COMPLETED' ? ( {exp.status === 'COMPLETED' ? (
<button <a
onClick={() => handleDownload(exp.id, exp.fileName, exp.mimeType || 'application/octet-stream')} 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" 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" /> <Download className="h-3.5 w-3.5" />
Tải xuống Tải xuống
</button> </a>
) : ( ) : (
<span className="text-xs text-slate-400 capitalize">{exp.status.toLowerCase()}</span> <span className="text-xs text-slate-400 capitalize">{exp.status.toLowerCase()}</span>
)} )}
......
...@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react'; ...@@ -3,7 +3,7 @@ import { useEffect, useState } from 'react';
import { Shield, Mail, UserCheck, Loader2, Save, KeyRound } from 'lucide-react'; import { Shield, Mail, UserCheck, Loader2, Save, KeyRound } from 'lucide-react';
export const Profile: React.FC = () => { export const Profile: React.FC = () => {
const { user, updateProfile } = useAuth(); const { user, updateProfile, changePassword } = useAuth();
const [fullName, setFullName] = useState(user?.fullName || ''); const [fullName, setFullName] = useState(user?.fullName || '');
const [oldPassword, setOldPassword] = useState(''); const [oldPassword, setOldPassword] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
...@@ -20,7 +20,14 @@ export const Profile: React.FC = () => { ...@@ -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.'); if (password && password !== confirmPassword) return setError('Mật khẩu xác nhận không khớp.');
setSubmitting(true); setSubmitting(true);
try { 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(''); setOldPassword(''); setPassword(''); setConfirmPassword('');
setMessage('Cập nhật hồ sơ thành công.'); setMessage('Cập nhật hồ sơ thành công.');
} catch (err: any) { } catch (err: any) {
......
import axios from 'axios'; 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(/\/$/, ''); .replace(/\/$/, '');
export const api = axios.create({ export const api = axios.create({
baseURL: API_BASE_URL, baseURL: API_BASE_URL,
withCredentials: true,
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
}); });
// Flag to prevent multiple concurrent token refresh requests
let isRefreshing = false; let isRefreshing = false;
let failedQueue: Array<{ let failedQueue: Array<{
resolve: (value: unknown) => void; resolve: (value?: unknown) => void;
reject: (error: unknown) => void; reject: (error: unknown) => void;
}> = []; }> = [];
const processQueue = (error: any, token: string | null = null) => { const processQueue = (error?: unknown) => {
failedQueue.forEach((prom) => { failedQueue.forEach((prom) => {
if (error) { if (error) {
prom.reject(error); prom.reject(error);
} else { } else {
prom.resolve(token); prom.resolve();
} }
}); });
failedQueue = []; 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( api.interceptors.response.use(
(response) => response, (response) => response,
async (error) => { async (error) => {
const originalRequest = error.config; const originalRequest = error.config;
// Avoid infinite loop if auth requests fail (like /auth/login or /auth/refresh itself) if (
if (originalRequest.url?.includes('/auth/login') || originalRequest.url?.includes('/auth/refresh')) { originalRequest.url?.includes('/auth/login') ||
originalRequest.url?.includes('/auth/refresh') ||
originalRequest.url?.includes('/auth/logout')
) {
return Promise.reject(error); return Promise.reject(error);
} }
if (error.response?.status === 401 && !originalRequest._retry) { if (error.response?.status === 401 && !originalRequest._retry) {
if (isRefreshing) { if (isRefreshing) {
// Queue the request until token is refreshed
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
failedQueue.push({ resolve, reject }); failedQueue.push({ resolve, reject });
}) })
.then((token) => { .then(() => api(originalRequest))
originalRequest.headers.Authorization = `Bearer ${token}`;
return api(originalRequest);
})
.catch((err) => Promise.reject(err)); .catch((err) => Promise.reject(err));
} }
originalRequest._retry = true; originalRequest._retry = true;
isRefreshing = true; isRefreshing = true;
const refreshToken = localStorage.getItem('refreshToken');
if (!refreshToken) {
processQueue(error, null);
isRefreshing = false;
handleLogout();
return Promise.reject(error);
}
try { try {
const response = await axios.post(`${API_BASE_URL}/auth/refresh`, { await api.post('/auth/refresh', {});
refreshToken, processQueue();
});
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;
return api(originalRequest); return api(originalRequest);
} catch (refreshError) { } catch (refreshError) {
processQueue(refreshError, null); processQueue(refreshError);
isRefreshing = false; notifyAuthExpired();
handleLogout();
return Promise.reject(refreshError); return Promise.reject(refreshError);
} finally {
isRefreshing = false;
} }
} }
...@@ -106,11 +70,12 @@ api.interceptors.response.use( ...@@ -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('accessToken');
localStorage.removeItem('refreshToken'); localStorage.removeItem('refreshToken');
localStorage.removeItem('user'); localStorage.removeItem('user');
if (window.location.pathname !== '/login') { window.dispatchEvent(new Event('auth:expired'));
window.location.href = '/login';
}
} }
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