Commit 9f29b2f6 authored by Nguyễn Minh Khánh's avatar Nguyễn Minh Khánh

fix conflict

parents 51cba087 87194288
Pipeline #52279 passed with stage
in 1 minute and 20 seconds
...@@ -24,4 +24,3 @@ dist-ssr ...@@ -24,4 +24,3 @@ dist-ssr
*.sw? *.sw?
.vercel .vercel
.env* .env*
.env
\ No newline at end of file
...@@ -32,3 +32,39 @@ deploy_job: ...@@ -32,3 +32,39 @@ deploy_job:
- runner-vpd-que - runner-vpd-que
only: only:
- staging - staging
stages:
- deploy
deploy_job:
stage: deploy
before_script:
- npm install -g vercel 2>/dev/null
script:
- |
DEPLOY_OUTPUT=$(vercel deploy \
--token=$VERCEL_TOKEN \
--yes \
2>&1)
echo "=== Full output ==="
echo "$DEPLOY_OUTPUT"
# Grep lấy đúng URL dạng https://xxx.vercel.app
DEPLOY_URL=$(echo "$DEPLOY_OUTPUT" | grep -Eo 'https://[a-zA-Z0-9._-]+\.vercel\.app' | head -1)
echo "Deploy URL: $DEPLOY_URL"
if [ -z "$DEPLOY_URL" ]; then
echo "ERROR: Không lấy được deploy URL!"
exit 1
fi
vercel alias set "$DEPLOY_URL" staging-$CI_PROJECT_NAME.vercel.app \
--token=$VERCEL_TOKEN
echo "Staging URL: https://staging-$CI_PROJECT_NAME.vercel.app"
tags:
- runner-vpd-que
only:
- staging
<!doctype html> <!doctype html>
<html lang="en"> <html lang="vi">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title> <title>Data Crawler</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
......
...@@ -10,14 +10,17 @@ import { JobDetail } from './pages/JobDetail'; ...@@ -10,14 +10,17 @@ import { JobDetail } from './pages/JobDetail';
import { Users } from './pages/Users'; import { Users } from './pages/Users';
import { Logs } from './pages/Logs'; import { Logs } from './pages/Logs';
import { Profile } from './pages/Profile'; import { Profile } from './pages/Profile';
import { ApiKeys } from './pages/ApiKeys';
import { ForgotPassword } from './pages/ForgotPassword'; import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword'; import { ResetPassword } from './pages/ResetPassword';
import { VerifyEmail } from './pages/VerifyEmail'; import { VerifyEmail } from './pages/VerifyEmail';
import { PageTitle } from './components/PageTitle';
export default function App() { export default function App() {
return ( return (
<AuthProvider> <AuthProvider>
<BrowserRouter> <BrowserRouter>
<PageTitle />
<Routes> <Routes>
{/* Guest Routes */} {/* Guest Routes */}
<Route <Route
...@@ -57,6 +60,7 @@ export default function App() { ...@@ -57,6 +60,7 @@ export default function App() {
{/* Profile */} {/* Profile */}
<Route path="profile" element={<Profile />} /> <Route path="profile" element={<Profile />} />
<Route path="api-keys" element={<ApiKeys />} />
{/* Admin Only Routes */} {/* Admin Only Routes */}
<Route <Route
......
import { useEffect } from 'react';
import { useLocation } from 'react-router-dom';
const TITLES: Record<string, string> = {
'/': 'Danh sách công việc | Data Crawler',
'/login': 'Đăng nhập | Data Crawler',
'/register': 'Đăng ký | Data Crawler',
'/forgot-password': 'Quên mật khẩu | Data Crawler',
'/reset-password': 'Đặt lại mật khẩu | Data Crawler',
'/verify-email': 'Xác minh email | Data Crawler',
'/profile': 'Hồ sơ cá nhân | Data Crawler',
'/api-keys': 'Quản lý API Key | Data Crawler',
'/users': 'Quản lý người dùng | Data Crawler',
'/logs': 'Nhật ký hệ thống | Data Crawler',
};
export function PageTitle() {
const { pathname } = useLocation();
useEffect(() => {
document.title = pathname.startsWith('/jobs/')
? 'Chi tiết công việc | Data Crawler'
: TITLES[pathname] ?? 'Data Crawler';
}, [pathname]);
return null;
}
import { useState, type InputHTMLAttributes } from 'react';
import { Eye, EyeOff, Lock } from 'lucide-react';
interface PasswordInputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'type'> {
label: string;
inputClassName?: string;
}
export function PasswordInput({
id,
label,
inputClassName = '',
...inputProps
}: PasswordInputProps) {
const [visible, setVisible] = useState(false);
return (
<div>
<label htmlFor={id} className="mb-1 block text-sm font-medium text-slate-700">
{label}
</label>
<div className="relative">
<Lock
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400"
/>
<input
{...inputProps}
id={id}
type={visible ? 'text' : 'password'}
className={`block w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-11 text-sm text-slate-900 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 ${inputClassName}`}
/>
<button
type="button"
onClick={() => setVisible((current) => !current)}
className="absolute inset-y-0 right-0 flex min-h-11 min-w-11 items-center justify-center rounded-r-lg text-slate-400 transition-colors hover:text-slate-700 focus:outline-none focus:ring-2 focus:ring-inset focus:ring-indigo-500"
aria-label={visible ? `Ẩn ${label.toLowerCase()}` : `Hiện ${label.toLowerCase()}`}
aria-pressed={visible}
>
{visible ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
</button>
</div>
</div>
);
}
...@@ -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 from 'react'; import React from 'react';
import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom'; import { Link, useNavigate, useLocation, Outlet } from 'react-router-dom';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { Database, Users, History, User, LogOut, Compass } from 'lucide-react'; import { Database, Users, History, User, LogOut, Compass, KeyRound } from 'lucide-react';
export const DashboardLayout: React.FC = () => { export const DashboardLayout: React.FC = () => {
const { user, logout, isAdmin } = useAuth(); const { user, logout, isAdmin } = useAuth();
...@@ -20,6 +20,12 @@ export const DashboardLayout: React.FC = () => { ...@@ -20,6 +20,12 @@ export const DashboardLayout: React.FC = () => {
icon: <Database className="h-5 w-5" />, icon: <Database className="h-5 w-5" />,
allowed: true, allowed: true,
}, },
{
name: 'Quản lý API Key',
path: '/api-keys',
icon: <KeyRound className="h-5 w-5" />,
allowed: true,
},
{ {
name: 'Quản lý Users', name: 'Quản lý Users',
path: '/users', path: '/users',
......
import { useCallback, useEffect, useState } from 'react';
import {
AlertTriangle,
Check,
Copy,
Eye,
EyeOff,
KeyRound,
Loader2,
Plus,
RefreshCw,
ShieldCheck,
Trash2,
X,
} from 'lucide-react';
import { apiKeysApi, type ApiKeyRecord, type CreatedApiKey } from '../services/api';
import { getApiErrorMessages } from '../utils/apiError';
type ExpirationPreset = 'none' | '7' | '30' | '90' | 'custom';
const relativeTime = new Intl.RelativeTimeFormat('vi', { numeric: 'auto' });
function formatRelativeTime(value: string | null) {
if (!value) return 'Chưa sử dụng';
const differenceInSeconds = Math.round((new Date(value).getTime() - Date.now()) / 1000);
const ranges: Array<[Intl.RelativeTimeFormatUnit, number]> = [
['year', 31_536_000],
['month', 2_592_000],
['week', 604_800],
['day', 86_400],
['hour', 3_600],
['minute', 60],
];
for (const [unit, seconds] of ranges) {
if (Math.abs(differenceInSeconds) >= seconds) {
return relativeTime.format(Math.round(differenceInSeconds / seconds), unit);
}
}
return relativeTime.format(differenceInSeconds, 'second');
}
function formatDate(value: string | null) {
if (!value) return 'Không hết hạn';
return new Intl.DateTimeFormat('vi-VN', {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(value));
}
function isExpired(key: ApiKeyRecord) {
return Boolean(key.expiresAt && new Date(key.expiresAt).getTime() <= Date.now());
}
function getExpiration(preset: ExpirationPreset, customDate: string) {
if (preset === 'none') return null;
if (preset === 'custom') {
if (!customDate) throw new Error('Vui lòng chọn ngày hết hạn.');
const expiration = new Date(`${customDate}T23:59:59.999`);
if (expiration.getTime() <= Date.now()) {
throw new Error('Ngày hết hạn phải ở trong tương lai.');
}
return expiration.toISOString();
}
const expiration = new Date();
expiration.setDate(expiration.getDate() + Number(preset));
return expiration.toISOString();
}
function todayInputValue() {
const now = new Date();
const offset = now.getTimezoneOffset();
return new Date(now.getTime() - offset * 60_000).toISOString().slice(0, 10);
}
export function ApiKeys() {
const [keys, setKeys] = useState<ApiKeyRecord[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [pageError, setPageError] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [name, setName] = useState('');
const [expirationPreset, setExpirationPreset] = useState<ExpirationPreset>('none');
const [customDate, setCustomDate] = useState('');
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState('');
const [generatedKey, setGeneratedKey] = useState<CreatedApiKey | null>(null);
const [showRawKey, setShowRawKey] = useState(false);
const [copied, setCopied] = useState(false);
const [updatingId, setUpdatingId] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<ApiKeyRecord | null>(null);
const [revoking, setRevoking] = useState(false);
const loadKeys = useCallback(async (silent = false) => {
if (silent) setRefreshing(true);
else setLoading(true);
try {
setKeys(await apiKeysApi.list());
setPageError('');
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể tải danh sách API Key.').join('\n'));
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
void loadKeys();
}, [loadKeys]);
const resetCreateForm = () => {
setName('');
setExpirationPreset('none');
setCustomDate('');
setCreateError('');
setCopied(false);
setShowRawKey(false);
};
const closeCreateForm = () => {
if (generatedKey) return;
resetCreateForm();
setShowCreate(false);
};
const handleCreate = async (event: React.FormEvent) => {
event.preventDefault();
const normalizedName = name.trim();
if (!normalizedName) {
setCreateError('Vui lòng nhập tên API Key.');
return;
}
let expiresAt: string | null;
try {
expiresAt = getExpiration(expirationPreset, customDate);
} catch (error) {
setCreateError(error instanceof Error ? error.message : 'Ngày hết hạn không hợp lệ.');
return;
}
setCreating(true);
setCreateError('');
try {
const created = await apiKeysApi.create({ name: normalizedName, expiresAt });
const publicKey: ApiKeyRecord = {
id: created.id,
userId: created.userId,
name: created.name,
keyPrefix: created.keyPrefix,
isActive: created.isActive,
expiresAt: created.expiresAt,
lastUsedAt: created.lastUsedAt,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
};
setKeys((current) => [publicKey, ...current]);
setGeneratedKey(created);
} catch (error: unknown) {
setCreateError(getApiErrorMessages(error, 'Không thể tạo API Key.').join('\n'));
} finally {
setCreating(false);
}
};
const acknowledgeGeneratedKey = () => {
setGeneratedKey(null);
resetCreateForm();
setShowCreate(false);
};
const copyGeneratedKey = async () => {
if (!generatedKey) return;
try {
await navigator.clipboard.writeText(generatedKey.rawKey);
setCopied(true);
window.setTimeout(() => setCopied(false), 2_000);
} catch {
setCreateError('Không thể sao chép tự động. Vui lòng chọn và sao chép key thủ công.');
setShowRawKey(true);
}
};
const handleStatusChange = async (key: ApiKeyRecord) => {
setUpdatingId(key.id);
setPageError('');
try {
const updated = await apiKeysApi.setActive(key.id, !key.isActive);
setKeys((current) => current.map((item) => item.id === key.id ? updated : item));
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể cập nhật trạng thái API Key.').join('\n'));
} finally {
setUpdatingId(null);
}
};
const handleRevoke = async () => {
if (!revokeTarget) return;
setRevoking(true);
setPageError('');
try {
await apiKeysApi.revoke(revokeTarget.id);
setKeys((current) => current.filter((key) => key.id !== revokeTarget.id));
setRevokeTarget(null);
} catch (error: unknown) {
setPageError(getApiErrorMessages(error, 'Không thể thu hồi API Key.').join('\n'));
} finally {
setRevoking(false);
}
};
return (
<div className="mx-auto max-w-7xl space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-2 inline-flex items-center gap-2 rounded-full border border-indigo-100 bg-indigo-50 px-3 py-1 text-xs font-semibold text-indigo-700">
<ShieldCheck className="h-3.5 w-3.5" />
Tích hợp an toàn
</div>
<h1 className="text-2xl font-bold tracking-tight text-slate-900">Quản lý API Key</h1>
<p className="mt-1 max-w-2xl text-sm text-slate-500">
Tạo và kiểm soát khóa truy cập dành cho ứng dụng bên ngoài. Khóa đầy đủ chỉ hiển thị một lần.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => void loadKeys(true)}
disabled={refreshing}
className="inline-flex items-center justify-center rounded-lg border border-slate-200 bg-white px-3 py-2 text-sm font-semibold text-slate-600 shadow-sm transition hover:bg-slate-50 disabled:opacity-60"
>
<RefreshCw className={`h-4 w-4 ${refreshing ? 'animate-spin' : ''}`} />
</button>
<button
type="button"
onClick={() => setShowCreate(true)}
className="inline-flex items-center justify-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-indigo-700"
>
<Plus className="mr-2 h-4 w-4" />
Tạo API Key
</button>
</div>
</div>
{pageError && (
<div className="whitespace-pre-line rounded-xl border border-rose-200 bg-rose-50 p-4 text-sm text-rose-700" role="alert">
{pageError}
</div>
)}
<div className="overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm">
<div className="border-b border-slate-100 px-5 py-4">
<h2 className="font-semibold text-slate-900">API Key của bạn</h2>
<p className="mt-0.5 text-xs text-slate-500">{keys.length} khóa đang được lưu</p>
</div>
{loading ? (
<div className="flex min-h-64 items-center justify-center text-sm text-slate-500">
<Loader2 className="mr-2 h-5 w-5 animate-spin text-indigo-500" />
Đang tải API Key...
</div>
) : keys.length === 0 ? (
<div className="flex min-h-64 flex-col items-center justify-center px-6 text-center">
<div className="mb-4 flex h-14 w-14 items-center justify-center rounded-2xl bg-slate-100 text-slate-400">
<KeyRound className="h-7 w-7" />
</div>
<h3 className="font-semibold text-slate-900">Chưa có API Key</h3>
<p className="mt-1 max-w-md text-sm text-slate-500">
Tạo khóa đầu tiên để xác thực các request từ ứng dụng hoặc quy trình tự động của bạn.
</p>
<button
type="button"
onClick={() => setShowCreate(true)}
className="mt-4 inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700"
>
<Plus className="mr-2 h-4 w-4" />
Tạo API Key
</button>
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-slate-100 text-left text-sm">
<thead className="bg-slate-50 text-xs font-semibold uppercase tracking-wide text-slate-500">
<tr>
<th className="px-5 py-3">Tên</th>
<th className="px-5 py-3">API Key</th>
<th className="px-5 py-3">Trạng thái</th>
<th className="px-5 py-3">Hết hạn</th>
<th className="px-5 py-3">Dùng lần cuối</th>
<th className="px-5 py-3">Ngày tạo</th>
<th className="px-5 py-3 text-right">Thao tác</th>
</tr>
</thead>
<tbody className="divide-y divide-slate-100">
{keys.map((key) => {
const expired = isExpired(key);
return (
<tr key={key.id} className="transition hover:bg-slate-50/70">
<td className="whitespace-nowrap px-5 py-4 font-semibold text-slate-900">{key.name}</td>
<td className="whitespace-nowrap px-5 py-4">
<code className="rounded-md bg-slate-100 px-2 py-1 text-xs text-slate-700">
{key.keyPrefix}••••••••
</code>
</td>
<td className="whitespace-nowrap px-5 py-4">
<div className="flex items-center gap-2">
<button
type="button"
role="switch"
aria-checked={key.isActive}
aria-label={`${key.isActive ? 'Tắt' : 'Bật'} API Key ${key.name}`}
onClick={() => void handleStatusChange(key)}
disabled={updatingId === key.id}
className={`relative inline-flex h-6 w-11 shrink-0 rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:opacity-50 ${
key.isActive ? 'bg-indigo-600' : 'bg-slate-300'
}`}
>
<span
className={`mt-0.5 inline-block h-5 w-5 rounded-full bg-white shadow transition-transform ${
key.isActive ? 'translate-x-5' : 'translate-x-0.5'
}`}
/>
</button>
<span className={`text-xs font-semibold ${key.isActive ? 'text-emerald-700' : 'text-slate-500'}`}>
{key.isActive ? 'Đang bật' : 'Đã tắt'}
</span>
</div>
</td>
<td className="whitespace-nowrap px-5 py-4">
<span className={expired ? 'font-semibold text-rose-600' : 'text-slate-500'}>
{expired ? `Đã hết hạn · ${formatDate(key.expiresAt)}` : formatDate(key.expiresAt)}
</span>
</td>
<td className="whitespace-nowrap px-5 py-4 text-slate-500" title={key.lastUsedAt ? formatDate(key.lastUsedAt) : undefined}>
{formatRelativeTime(key.lastUsedAt)}
</td>
<td className="whitespace-nowrap px-5 py-4 text-slate-500">{formatDate(key.createdAt)}</td>
<td className="whitespace-nowrap px-5 py-4 text-right">
<button
type="button"
onClick={() => setRevokeTarget(key)}
className="inline-flex items-center gap-1.5 font-semibold text-rose-600 transition hover:text-rose-800"
>
<Trash2 className="h-4 w-4" />
Thu hồi
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{showCreate && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4 backdrop-blur-sm"
onMouseDown={(event) => {
if (event.target === event.currentTarget) closeCreateForm();
}}
>
<div
className="w-full max-w-lg rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl"
role="dialog"
aria-modal="true"
aria-labelledby="api-key-dialog-title"
>
{generatedKey ? (
<div className="space-y-5">
<div>
<div className="mx-auto mb-3 flex h-11 w-11 items-center justify-center rounded-xl bg-emerald-100 text-emerald-700">
<Check className="h-6 w-6" />
</div>
<h2 id="api-key-dialog-title" className="text-xl font-bold text-slate-900">API Key đã được tạo</h2>
<p className="mt-1 text-sm text-slate-500">Khóa “{generatedKey.name}” đã sẵn sàng để sử dụng.</p>
</div>
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-600" />
<div>
<p className="font-bold">Hãy sao chép key này ngay bây giờ</p>
<p className="mt-1 text-amber-800">
Vì lý do bảo mật, hệ thống sẽ không hiển thị lại khóa đầy đủ sau khi bạn đóng cửa sổ này.
</p>
</div>
</div>
</div>
{createError && (
<div className="whitespace-pre-line rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700" role="alert">
{createError}
</div>
)}
<div>
<label htmlFor="generated-api-key" className="mb-1.5 block text-sm font-semibold text-slate-700">
API Key
</label>
<div className="flex rounded-lg border border-slate-300 bg-slate-50 focus-within:border-indigo-500 focus-within:ring-1 focus-within:ring-indigo-500">
<input
id="generated-api-key"
type={showRawKey ? 'text' : 'password'}
value={generatedKey.rawKey}
readOnly
className="min-w-0 flex-1 bg-transparent px-3 py-2.5 font-mono text-sm text-slate-900 outline-none"
/>
<button
type="button"
onClick={() => setShowRawKey((current) => !current)}
className="px-3 text-slate-500 hover:text-slate-800"
aria-label={showRawKey ? 'Ẩn API Key' : 'Hiện API Key'}
>
{showRawKey ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
<button
type="button"
onClick={() => void copyGeneratedKey()}
className="m-1 inline-flex items-center rounded-md bg-indigo-600 px-3 text-sm font-semibold text-white hover:bg-indigo-700"
>
{copied ? <Check className="mr-1.5 h-4 w-4" /> : <Copy className="mr-1.5 h-4 w-4" />}
{copied ? 'Đã chép' : 'Sao chép'}
</button>
</div>
</div>
<button
type="button"
onClick={acknowledgeGeneratedKey}
className="w-full rounded-lg bg-slate-900 px-4 py-2.5 text-sm font-semibold text-white transition hover:bg-slate-800"
>
Tôi đã sao chép key này
</button>
</div>
) : (
<div>
<div className="flex items-start justify-between">
<div>
<h2 id="api-key-dialog-title" className="text-xl font-bold text-slate-900">Tạo API Key mới</h2>
<p className="mt-1 text-sm text-slate-500">Đặt tên dễ nhận biết và chọn thời hạn phù hợp.</p>
</div>
<button
type="button"
onClick={closeCreateForm}
className="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-700"
aria-label="Đóng"
>
<X className="h-5 w-5" />
</button>
</div>
{createError && (
<div className="mt-4 whitespace-pre-line rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700" role="alert">
{createError}
</div>
)}
<form onSubmit={handleCreate} className="mt-5 space-y-4">
<div>
<label htmlFor="api-key-name" className="mb-1.5 block text-sm font-semibold text-slate-700">
Tên key <span className="text-rose-500">*</span>
</label>
<input
id="api-key-name"
value={name}
onChange={(event) => setName(event.target.value)}
maxLength={100}
required
autoFocus
placeholder="Ví dụ: Production App"
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div>
<label htmlFor="api-key-expiration" className="mb-1.5 block text-sm font-semibold text-slate-700">
Thời hạn
</label>
<select
id="api-key-expiration"
value={expirationPreset}
onChange={(event) => setExpirationPreset(event.target.value as ExpirationPreset)}
className="w-full rounded-lg border border-slate-300 bg-white px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
>
<option value="none">Không hết hạn</option>
<option value="7">7 ngày</option>
<option value="30">30 ngày</option>
<option value="90">90 ngày</option>
<option value="custom">Chọn ngày cụ thể</option>
</select>
</div>
{expirationPreset === 'custom' && (
<div>
<label htmlFor="api-key-custom-date" className="mb-1.5 block text-sm font-semibold text-slate-700">
Ngày hết hạn
</label>
<input
id="api-key-custom-date"
type="date"
min={todayInputValue()}
value={customDate}
onChange={(event) => setCustomDate(event.target.value)}
required
className="w-full rounded-lg border border-slate-300 px-3 py-2.5 text-sm outline-none focus:border-indigo-500 focus:ring-1 focus:ring-indigo-500"
/>
</div>
)}
<div className="flex justify-end gap-2 border-t border-slate-100 pt-4">
<button
type="button"
onClick={closeCreateForm}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50"
>
Hủy
</button>
<button
type="submit"
disabled={creating}
className="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60"
>
{creating ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <KeyRound className="mr-2 h-4 w-4" />}
{creating ? 'Đang tạo...' : 'Tạo key'}
</button>
</div>
</form>
</div>
)}
</div>
</div>
)}
{revokeTarget && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-slate-950/50 p-4 backdrop-blur-sm">
<div
className="w-full max-w-md rounded-2xl border border-slate-200 bg-white p-6 shadow-2xl"
role="alertdialog"
aria-modal="true"
aria-labelledby="revoke-api-key-title"
>
<div className="mx-auto mb-4 flex h-11 w-11 items-center justify-center rounded-xl bg-rose-100 text-rose-600">
<AlertTriangle className="h-6 w-6" />
</div>
<h2 id="revoke-api-key-title" className="text-lg font-bold text-slate-900">Thu hồi API Key?</h2>
<p className="mt-2 text-sm leading-6 text-slate-600">
Key <strong className="text-slate-900">{revokeTarget.name}</strong> sẽ bị xóa vĩnh viễn.
Mọi ứng dụng đang sử dụng key này sẽ mất quyền truy cập ngay lập tức.
</p>
<div className="mt-6 flex justify-end gap-2">
<button
type="button"
onClick={() => setRevokeTarget(null)}
disabled={revoking}
className="rounded-lg border border-slate-200 px-4 py-2 text-sm font-semibold text-slate-600 hover:bg-slate-50 disabled:opacity-60"
>
Hủy
</button>
<button
type="button"
onClick={() => void handleRevoke()}
disabled={revoking}
className="inline-flex items-center rounded-lg bg-rose-600 px-4 py-2 text-sm font-semibold text-white hover:bg-rose-700 disabled:opacity-60"
>
{revoking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Trash2 className="mr-2 h-4 w-4" />}
{revoking ? 'Đang thu hồi...' : 'Thu hồi vĩnh viễn'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
...@@ -2,7 +2,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' ...@@ -2,7 +2,15 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { api } from '../services/api'; import { api } from '../services/api';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { Plus, XCircle, Search, RefreshCw, ExternalLink } from 'lucide-react'; import {
Plus,
XCircle,
Search,
RefreshCw,
ExternalLink,
SlidersHorizontal,
RotateCcw,
} from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress'; import { getJobProgress } from '../utils/jobProgress';
const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT']; const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'];
...@@ -31,6 +39,30 @@ interface JobsMeta { ...@@ -31,6 +39,30 @@ interface JobsMeta {
totalPages: number; totalPages: number;
} }
type JobStatus = CrawlJob['status'];
type JobMode = CrawlJob['mode'];
type SortOption = 'createdAt:desc' | 'createdAt:asc' | 'status:asc' | 'totalPages:desc';
const STATUS_OPTIONS: Array<{ value: JobStatus | ''; label: string }> = [
{ value: '', label: 'Tất cả trạng thái' },
{ value: 'PENDING', label: 'Khởi tạo' },
{ value: 'QUEUED', label: 'Đang chờ' },
{ value: 'RUNNING', label: 'Đang chạy' },
{ value: 'PROCESSING_EXPORT', label: 'Đang xuất file' },
{ value: 'COMPLETED', label: 'Hoàn thành' },
{ value: 'FAILED', label: 'Thất bại' },
{ value: 'CANCELED', label: 'Đã hủy' },
{ value: 'EXPIRED', label: 'Đã hết hạn' },
];
const MODE_OPTIONS: Array<{ value: JobMode | ''; label: string }> = [
{ value: '', label: 'Tất cả chế độ' },
{ value: 'SCRAPE', label: 'Scrape một trang' },
{ value: 'CRAWL', label: 'Crawl website' },
{ value: 'SITEMAP', label: 'Sitemap' },
{ value: 'URL_LIST', label: 'Danh sách URL' },
];
export const Dashboard: React.FC = () => { export const Dashboard: React.FC = () => {
const { isCrawler } = useAuth(); const { isCrawler } = useAuth();
const [jobs, setJobs] = useState<CrawlJob[]>([]); const [jobs, setJobs] = useState<CrawlJob[]>([]);
...@@ -40,6 +72,12 @@ export const Dashboard: React.FC = () => { ...@@ -40,6 +72,12 @@ export const Dashboard: React.FC = () => {
const [loadError, setLoadError] = useState(''); const [loadError, setLoadError] = useState('');
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const latestRequestRef = useRef(0); const latestRequestRef = useRef(0);
const [searchInput, setSearchInput] = useState('');
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<JobStatus | ''>('');
const [modeFilter, setModeFilter] = useState<JobMode | ''>('');
const [sort, setSort] = useState<SortOption>('createdAt:desc');
const [pageSize, setPageSize] = useState(20);
// Form states // Form states
const [startUrl, setStartUrl] = useState(''); const [startUrl, setStartUrl] = useState('');
...@@ -55,7 +93,18 @@ export const Dashboard: React.FC = () => { ...@@ -55,7 +93,18 @@ export const Dashboard: React.FC = () => {
if (!silent) setLoading(true); if (!silent) setLoading(true);
else setRefreshing(true); else setRefreshing(true);
try { try {
const response = await api.get('/crawl-jobs', { params: { page, limit: 20 } }); const [sortBy, order] = sort.split(':');
const response = await api.get('/crawl-jobs', {
params: {
page,
limit: pageSize,
...(search && { search }),
...(statusFilter && { status: statusFilter }),
...(modeFilter && { mode: modeFilter }),
sortBy,
order,
},
});
if (response.data.success && requestId === latestRequestRef.current) { if (response.data.success && requestId === latestRequestRef.current) {
setJobs(response.data.data.jobs || response.data.data.items || []); setJobs(response.data.data.jobs || response.data.data.items || []);
if (response.data.data.meta) setMeta(response.data.data.meta); if (response.data.data.meta) setMeta(response.data.data.meta);
...@@ -72,12 +121,23 @@ export const Dashboard: React.FC = () => { ...@@ -72,12 +121,23 @@ export const Dashboard: React.FC = () => {
setRefreshing(false); setRefreshing(false);
} }
} }
}, []); }, [modeFilter, pageSize, search, sort, statusFilter]);
useEffect(() => { useEffect(() => {
void fetchJobs(1); void fetchJobs(1);
}, [fetchJobs]); }, [fetchJobs]);
const hasFilters = Boolean(search || statusFilter || modeFilter || sort !== 'createdAt:desc' || pageSize !== 20);
const resetFilters = () => {
setSearchInput('');
setSearch('');
setStatusFilter('');
setModeFilter('');
setSort('createdAt:desc');
setPageSize(20);
};
const hasActiveJobs = useMemo( const hasActiveJobs = useMemo(
() => jobs.some((job) => ACTIVE_STATUSES.includes(job.status)), () => jobs.some((job) => ACTIVE_STATUSES.includes(job.status)),
[jobs], [jobs],
...@@ -212,6 +272,115 @@ export const Dashboard: React.FC = () => { ...@@ -212,6 +272,115 @@ export const Dashboard: React.FC = () => {
{/* Main Table Card */} {/* Main Table Card */}
<div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden"> <div className="bg-white rounded-xl shadow-sm border border-slate-200 overflow-hidden">
<div className="border-b border-slate-200 bg-slate-50/60 p-4">
<form
className="flex flex-col gap-3 xl:flex-row xl:items-center"
onSubmit={(event) => {
event.preventDefault();
setSearch(searchInput.trim());
}}
role="search"
>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-400" />
<input
type="search"
value={searchInput}
onChange={(event) => setSearchInput(event.target.value)}
placeholder="Tìm theo URL hoặc tên miền..."
aria-label="Tìm kiếm crawl jobs"
className="h-10 w-full rounded-lg border border-slate-300 bg-white pl-9 pr-24 text-sm text-slate-800 outline-none transition focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100"
/>
<button
type="submit"
className="absolute right-1.5 top-1/2 -translate-y-1/2 rounded-md bg-slate-900 px-3 py-1.5 text-xs font-semibold text-white transition hover:bg-slate-700 focus:outline-none focus:ring-2 focus:ring-indigo-300"
>
Tìm kiếm
</button>
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4 xl:flex">
<label className="relative">
<span className="sr-only">Lọc theo trạng thái</span>
<select
value={statusFilter}
onChange={(event) => setStatusFilter(event.target.value as JobStatus | '')}
className="h-10 w-full appearance-none rounded-lg border border-slate-300 bg-white py-2 pl-3 pr-8 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-44"
>
{STATUS_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
<SlidersHorizontal className="pointer-events-none absolute right-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-slate-400" />
</label>
<label>
<span className="sr-only">Lọc theo chế độ crawl</span>
<select
value={modeFilter}
onChange={(event) => setModeFilter(event.target.value as JobMode | '')}
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-40"
>
{MODE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
</label>
<label>
<span className="sr-only">Sắp xếp danh sách</span>
<select
value={sort}
onChange={(event) => setSort(event.target.value as SortOption)}
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-44"
>
<option value="createdAt:desc">Mới nhất trước</option>
<option value="createdAt:asc">Cũ nhất trước</option>
<option value="status:asc">Theo trạng thái</option>
<option value="totalPages:desc">Nhiều trang nhất</option>
</select>
</label>
<label>
<span className="sr-only">Số jobs mỗi trang</span>
<select
value={pageSize}
onChange={(event) => setPageSize(Number(event.target.value))}
className="h-10 w-full rounded-lg border border-slate-300 bg-white px-3 text-sm text-slate-700 outline-none focus:border-indigo-500 focus:ring-2 focus:ring-indigo-100 xl:w-28"
>
<option value={10}>10 / trang</option>
<option value={20}>20 / trang</option>
<option value={50}>50 / trang</option>
</select>
</label>
</div>
{hasFilters && (
<button
type="button"
onClick={resetFilters}
className="inline-flex h-10 shrink-0 items-center justify-center gap-1.5 rounded-lg px-3 text-sm font-medium text-slate-500 transition hover:bg-slate-200 hover:text-slate-800"
>
<RotateCcw className="h-4 w-4" />
Xóa lọc
</button>
)}
</form>
<div className="mt-3 flex flex-wrap items-center justify-between gap-2 text-xs text-slate-500">
<p>
{loading ? 'Đang tải danh sách…' : `Tìm thấy ${meta.total} job`}
{search && <> cho “<span className="font-semibold text-slate-700">{search}</span></>}
</p>
{hasActiveJobs && (
<span className="inline-flex items-center gap-1.5 font-medium text-blue-600">
<span className="h-2 w-2 animate-pulse rounded-full bg-blue-500" />
Tự động cập nhật mỗi 5 giây
</span>
)}
</div>
</div>
{loading ? ( {loading ? (
<div className="flex justify-center items-center py-20"> <div className="flex justify-center items-center py-20">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div> <div className="h-8 w-8 animate-spin rounded-full border-4 border-indigo-600 border-t-transparent"></div>
...@@ -221,11 +390,24 @@ export const Dashboard: React.FC = () => { ...@@ -221,11 +390,24 @@ export const Dashboard: React.FC = () => {
<div className="mx-auto w-12 h-12 flex items-center justify-center bg-slate-100 rounded-full text-slate-400 mb-3"> <div className="mx-auto w-12 h-12 flex items-center justify-center bg-slate-100 rounded-full text-slate-400 mb-3">
<Search className="h-6 w-6" /> <Search className="h-6 w-6" />
</div> </div>
<h3 className="font-semibold text-slate-800 text-lg">Chưa có job nào</h3> <h3 className="font-semibold text-slate-800 text-lg">
{hasFilters ? 'Không tìm thấy job phù hợp' : 'Chưa có job nào'}
</h3>
<p className="text-slate-500 text-sm mt-1 max-w-md mx-auto"> <p className="text-slate-500 text-sm mt-1 max-w-md mx-auto">
Hệ thống chưa ghi nhận tiến trình cào dữ liệu nào. Hãy bắt đầu bằng việc tạo một Job mới! {hasFilters
? 'Thử thay đổi từ khóa hoặc xóa bớt bộ lọc để xem thêm kết quả.'
: 'Hệ thống chưa ghi nhận tiến trình cào dữ liệu nào. Hãy bắt đầu bằng việc tạo một Job mới!'}
</p> </p>
{isCrawler && ( {hasFilters ? (
<button
type="button"
onClick={resetFilters}
className="mt-4 inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-4 py-2 text-sm font-medium text-slate-700 transition hover:bg-slate-50"
>
<RotateCcw className="h-4 w-4" />
Xóa tất cả bộ lọc
</button>
) : isCrawler && (
<button <button
onClick={() => setShowCreateModal(true)} onClick={() => setShowCreateModal(true)}
className="mt-4 inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 text-sm transition-colors" className="mt-4 inline-flex items-center px-4 py-2 bg-indigo-600 text-white font-medium rounded-lg hover:bg-indigo-700 text-sm transition-colors"
...@@ -311,8 +493,10 @@ export const Dashboard: React.FC = () => { ...@@ -311,8 +493,10 @@ export const Dashboard: React.FC = () => {
)} )}
{!loading && meta.totalPages > 1 && ( {!loading && meta.totalPages > 1 && (
<div className="flex items-center justify-between border-t border-slate-100 bg-slate-50 px-6 py-3 text-sm"> <div className="flex flex-col gap-3 border-t border-slate-100 bg-slate-50 px-4 py-3 text-sm sm:flex-row sm:items-center sm:justify-between sm:px-6">
<span className="text-slate-500">Trang {meta.page} / {meta.totalPages} · {meta.total} job</span> <span className="text-slate-500">
Trang {meta.page} / {meta.totalPages} · Hiển thị {(meta.page - 1) * meta.limit + 1}{Math.min(meta.page * meta.limit, meta.total)} trên {meta.total} job
</span>
<div className="flex gap-2"> <div className="flex gap-2">
<button <button
type="button" type="button"
......
...@@ -5,23 +5,136 @@ import { api } from '../services/api'; ...@@ -5,23 +5,136 @@ import { api } from '../services/api';
export const ForgotPassword = () => { export const ForgotPassword = () => {
const [email, setEmail] = useState(''); const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false); const [sending, setSending] = useState(false);
const [resending, setResending] = useState(false);
const [message, setMessage] = useState(''); const [message, setMessage] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const sendResetEmail = async (isResend = false) => {
if (isResend) setResending(true);
else setSending(true);
setError('');
try {
const response = await api.post('/auth/forgot-password', { email });
setMessage(
isResend
? 'Email đặt lại mật khẩu đã được gửi lại. Vui lòng kiểm tra cả thư rác.'
: response.data.message || 'Vui lòng kiểm tra email để đặt lại mật khẩu.',
);
} catch (err: any) {
setError(
err.response?.data?.message ||
(isResend
? 'Không thể gửi lại email đặt lại mật khẩu. Vui lòng thử lại sau.'
: 'Không thể gửi yêu cầu đặt lại mật khẩu.'),
);
} finally {
if (isResend) setResending(false);
else setSending(false);
}
};
const submit = async (event: React.FormEvent) => { const submit = async (event: React.FormEvent) => {
event.preventDefault(); setLoading(true); setError(''); event.preventDefault();
try { const res = await api.post('/auth/forgot-password', { email }); setMessage(res.data.message || 'Vui lòng kiểm tra email để đặt lại mật khẩu.'); } await sendResetEmail();
catch (err: any) { setError(err.response?.data?.message || 'Không thể gửi yêu cầu.'); }
finally { setLoading(false); }
}; };
return <AuthCard title="Quên mật khẩu" subtitle="Nhập email để nhận liên kết đặt lại mật khẩu.">
{message ? <div className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700">{message}</div> : <form onSubmit={submit} className="space-y-5"> return (
{error && <div className="rounded-lg bg-rose-50 p-3 text-sm text-rose-700">{error}</div>} <AuthCard title="Quên mật khẩu" subtitle="Nhập email để nhận liên kết đặt lại mật khẩu.">
<label className="block text-sm font-medium text-slate-700">Email<div className="relative mt-1"><Mail className="absolute left-3 top-2.5 h-5 w-5 text-slate-400"/><input type="email" required value={email} onChange={e=>setEmail(e.target.value)} className="w-full rounded-lg border border-slate-300 py-2.5 pl-10 pr-3" /></div></label> {message ? (
<button disabled={loading} className="flex w-full justify-center rounded-lg bg-indigo-600 py-3 text-sm font-semibold text-white">{loading?<Loader2 className="h-5 w-5 animate-spin"/>:'Gửi liên kết đặt lại'}</button> <div className="space-y-4">
</form>} <div
<div className="text-center text-sm"><Link to="/login" className="font-medium text-indigo-600">← Quay lại đăng nhập</Link></div> className="rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700"
</AuthCard>; aria-live="polite"
>
<p>{message}</p>
<p className="mt-1">
Email được gửi tới <strong>{email}</strong>.
</p>
</div>
{error && (
<div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700" role="alert">
{error}
</div>
)}
<button
type="button"
onClick={() => void sendResetEmail(true)}
disabled={resending}
className="flex min-h-11 w-full items-center justify-center rounded-lg border border-indigo-200 bg-white px-4 text-sm font-semibold text-indigo-700 hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{resending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Gửi lại email đặt lại mật khẩu
</button>
</div>
) : (
<form onSubmit={submit} className="space-y-5">
{error && (
<div className="rounded-lg bg-rose-50 p-3 text-sm text-rose-700" role="alert">
{error}
</div>
)}
<div>
<label htmlFor="forgot-password-email" className="block text-sm font-medium text-slate-700">
Email
</label>
<div className="relative mt-1">
<Mail
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-slate-400"
/>
<input
id="forgot-password-email"
name="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(event) => setEmail(event.target.value)}
className="min-h-11 w-full rounded-lg border border-slate-300 py-2.5 pl-10 pr-3 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
<button
type="submit"
disabled={sending}
className="flex min-h-11 w-full items-center justify-center rounded-lg bg-indigo-600 py-3 text-sm font-semibold text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:bg-indigo-400"
>
{sending ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Gửi liên kết đặt lại'}
</button>
</form>
)}
<div className="text-center text-sm">
<Link to="/login" className="font-medium text-indigo-600">
← Quay lại đăng nhập
</Link>
</div>
</AuthCard>
);
}; };
export const AuthCard = ({title, subtitle, children}:{title:string;subtitle:string;children:React.ReactNode}) => <div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12"><div className="w-full max-w-md space-y-6 rounded-2xl border border-slate-100 bg-white p-8 shadow-xl"><div className="text-center"><div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-indigo-600 text-white"><Database className="h-6 w-6"/></div><h1 className="mt-5 text-2xl font-bold text-slate-900">{title}</h1><p className="mt-2 text-sm text-slate-500">{subtitle}</p></div>{children}</div></div>; export const AuthCard = ({
\ No newline at end of file title,
subtitle,
children,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
}) => (
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12">
<div className="w-full max-w-md space-y-6 rounded-2xl border border-slate-100 bg-white p-8 shadow-xl">
<div className="text-center">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-xl bg-indigo-600 text-white">
<Database className="h-6 w-6" />
</div>
<h1 className="mt-5 text-2xl font-bold text-slate-900">{title}</h1>
<p className="mt-2 text-sm text-slate-500">{subtitle}</p>
</div>
{children}
</div>
</div>
);
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,
...@@ -14,6 +14,13 @@ import { ...@@ -14,6 +14,13 @@ import {
Download, Download,
Loader2, Loader2,
Image as ImageIcon, Image as ImageIcon,
Eye,
Search,
Filter,
Copy,
Check,
Code,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress'; import { getJobProgress } from '../utils/jobProgress';
...@@ -51,6 +58,7 @@ interface CrawlJob { ...@@ -51,6 +58,7 @@ interface CrawlJob {
interface CrawlPage { interface CrawlPage {
id: string; id: string;
url: string; url: string;
normalizedUrl?: string;
title: string | null; title: string | null;
description: string | null; description: string | null;
status: string; status: string;
...@@ -61,6 +69,10 @@ interface CrawlPage { ...@@ -61,6 +69,10 @@ interface CrawlPage {
warnings: string[]; warnings: string[];
dataQualityScore: number | null; dataQualityScore: number | null;
wordCount: number | null; wordCount: number | null;
contentHash?: string | null;
rawMarkdown?: string | null;
mainContent?: string | null;
cleanText?: string | null;
} }
interface CrawlExport { interface CrawlExport {
...@@ -94,6 +106,16 @@ export const JobDetail: React.FC = () => { ...@@ -94,6 +106,16 @@ export const JobDetail: React.FC = () => {
const [exports, setExports] = useState<CrawlExport[]>([]); const [exports, setExports] = useState<CrawlExport[]>([]);
const [assets, setAssets] = useState<CrawlAsset[]>([]); const [assets, setAssets] = useState<CrawlAsset[]>([]);
// Filter States for Pages
const [searchQuery, setSearchQuery] = useState('');
const [pageStatusFilter, setPageStatusFilter] = useState('ALL');
const [minQualityFilter, setMinQualityFilter] = useState<number | 'ALL'>('ALL');
// Preview Modal States
const [selectedPage, setSelectedPage] = useState<CrawlPage | null>(null);
const [previewTab, setPreviewTab] = useState<'clean' | 'raw' | 'text'>('clean');
const [copied, setCopied] = useState(false);
const [loadingJob, setLoadingJob] = useState(true); const [loadingJob, setLoadingJob] = useState(true);
const [loadingPages, setLoadingPages] = useState(true); const [loadingPages, setLoadingPages] = useState(true);
const [loadingAssets, setLoadingAssets] = useState(true); const [loadingAssets, setLoadingAssets] = useState(true);
...@@ -131,25 +153,43 @@ export const JobDetail: React.FC = () => { ...@@ -131,25 +153,43 @@ export const JobDetail: React.FC = () => {
} }
}, [id]); }, [id]);
const fetchPages = useCallback(async (page = 1, silent = false) => { const fetchPages = useCallback(
const requestId = ++latestPagesRequestRef.current; async (
try { page = 1,
if (!silent) setLoadingPages(true); silent = false,
const res = await api.get(`/crawl-jobs/${id}/pages`, { params: { page, limit: 20 } }); search = searchQuery,
if (res.data.success && requestId === latestPagesRequestRef.current) { status = pageStatusFilter,
setPages(res.data.data.items || []); minQuality = minQualityFilter
setPagesMeta(res.data.data.meta); ) => {
setPagesError(''); const requestId = ++latestPagesRequestRef.current;
} try {
} catch (e) { if (!silent) setLoadingPages(true);
console.error('Failed to fetch pages', e); const params: Record<string, any> = {
if (requestId === latestPagesRequestRef.current) { page,
setPagesError('Không thể tải danh sách trang đã cào.'); limit: 20,
preview: true,
};
if (search.trim()) params.search = search.trim();
if (status !== 'ALL') params.status = status;
if (minQuality !== 'ALL') params.minQualityScore = minQuality;
const res = await api.get(`/crawl-jobs/${id}/pages`, { params });
if (res.data.success && requestId === latestPagesRequestRef.current) {
setPages(res.data.data.items || []);
setPagesMeta(res.data.data.meta);
setPagesError('');
}
} catch (e) {
console.error('Failed to fetch pages', e);
if (requestId === latestPagesRequestRef.current) {
setPagesError('Không thể tải danh sách trang đã cào.');
}
} finally {
if (!silent && requestId === latestPagesRequestRef.current) setLoadingPages(false);
} }
} finally { },
if (!silent && requestId === latestPagesRequestRef.current) setLoadingPages(false); [id, searchQuery, pageStatusFilter, minQualityFilter]
} );
}, [id]);
const fetchExports = useCallback(async () => { const fetchExports = useCallback(async () => {
try { try {
...@@ -162,34 +202,37 @@ export const JobDetail: React.FC = () => { ...@@ -162,34 +202,37 @@ export const JobDetail: React.FC = () => {
} }
}, [id]); }, [id]);
const fetchAssets = useCallback(async (typeFilter = selectedAssetTypeFilter, silent = false) => { const fetchAssets = useCallback(
const requestId = ++latestAssetsRequestRef.current; async (typeFilter = selectedAssetTypeFilter, silent = false) => {
try { const requestId = ++latestAssetsRequestRef.current;
if (!silent) setLoadingAssets(true); try {
const res = await api.get(`/crawl-jobs/${id}/assets`, { if (!silent) setLoadingAssets(true);
params: { const res = await api.get(`/crawl-jobs/${id}/assets`, {
assetType: typeFilter === 'ALL' ? undefined : typeFilter, params: {
}, assetType: typeFilter === 'ALL' ? undefined : typeFilter,
}); },
if (res.data.success && requestId === latestAssetsRequestRef.current) { });
setAssets(res.data.data || []); if (res.data.success && requestId === latestAssetsRequestRef.current) {
setAssetsError(''); setAssets(res.data.data || []);
} setAssetsError('');
} catch (e) { }
console.error('Failed to fetch assets', e); } catch (e) {
if (requestId === latestAssetsRequestRef.current) { console.error('Failed to fetch assets', e);
setAssetsError('Không thể tải danh sách tài nguyên.'); if (requestId === latestAssetsRequestRef.current) {
setAssetsError('Không thể tải danh sách tài nguyên.');
}
} finally {
if (!silent && requestId === latestAssetsRequestRef.current) setLoadingAssets(false);
} }
} finally { },
if (!silent && requestId === latestAssetsRequestRef.current) setLoadingAssets(false); [id, selectedAssetTypeFilter]
} );
}, [id, selectedAssetTypeFilter]);
// Initial load // Initial load
useEffect(() => { useEffect(() => {
if (!id) return; if (!id) return;
void fetchJob(); void fetchJob();
void fetchPages(); void fetchPages(1);
void fetchExports(); void fetchExports();
void fetchAssets(); void fetchAssets();
}, [fetchExports, fetchJob, fetchPages, fetchAssets, id]); }, [fetchExports, fetchJob, fetchPages, fetchAssets, id]);
...@@ -206,7 +249,7 @@ export const JobDetail: React.FC = () => { ...@@ -206,7 +249,7 @@ export const JobDetail: React.FC = () => {
await Promise.all([ await Promise.all([
fetchJob(), fetchJob(),
fetchPages(pagesMeta.page, true), fetchPages(pagesMeta.page, true),
fetchAssets(selectedAssetTypeFilter, true) fetchAssets(selectedAssetTypeFilter, true),
]); ]);
if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS); if (!stopped) timeout = setTimeout(poll, POLL_INTERVAL_MS);
}; };
...@@ -244,36 +287,10 @@ export const JobDetail: React.FC = () => { ...@@ -244,36 +287,10 @@ export const JobDetail: React.FC = () => {
} }
}; };
const handleDownload = async (exportId: string, fileName: string, _mimeType: string) => { const copyToClipboard = (text: string) => {
try { void navigator.clipboard.writeText(text);
console.log(`Downloading export ${exportId}, requesting blob...`); setCopied(true);
const res = await api.get(`/exports/${exportId}/download`, { responseType: 'blob' }); setTimeout(() => setCopied(false), 2000);
const blob = res.data; // res.data đã là Blob do responseType: 'blob'
console.log('Downloaded blob type:', blob.type, 'size:', blob.size);
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 getStatusConfig = (status: string) => { const getStatusConfig = (status: string) => {
...@@ -341,7 +358,7 @@ export const JobDetail: React.FC = () => { ...@@ -341,7 +358,7 @@ export const JobDetail: React.FC = () => {
const statusConfig = getStatusConfig(job.status); const statusConfig = getStatusConfig(job.status);
return ( return (
<div className="space-y-6 max-w-5xl mx-auto"> <div className="space-y-6 max-w-6xl mx-auto">
{/* Back + Header */} {/* Back + Header */}
<div className="flex items-start justify-between gap-4"> <div className="flex items-start justify-between gap-4">
<div> <div>
...@@ -372,7 +389,11 @@ export const JobDetail: React.FC = () => { ...@@ -372,7 +389,11 @@ export const JobDetail: React.FC = () => {
</button> </button>
)} )}
<button <button
onClick={() => { void fetchJob(); void fetchPages(pagesMeta.page); void fetchExports(); }} onClick={() => {
void fetchJob();
void fetchPages(pagesMeta.page);
void fetchExports();
}}
className="p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors" className="p-2 bg-white border border-slate-200 rounded-lg text-slate-600 hover:bg-slate-50 transition-colors"
title="Làm mới" title="Làm mới"
> >
...@@ -419,13 +440,11 @@ export const JobDetail: React.FC = () => { ...@@ -419,13 +440,11 @@ export const JobDetail: React.FC = () => {
</div> </div>
<div className="w-full bg-slate-100 rounded-full h-3 overflow-hidden"> <div className="w-full bg-slate-100 rounded-full h-3 overflow-hidden">
{isActive && !hasProgressData ? ( {isActive && !hasProgressData ? (
<div className="h-3 w-1/3 animate-pulse rounded-full bg-indigo-500" aria-label="Đang xử lý, chưa có số liệu tiến độ" /> <div className="h-3 w-1/3 animate-pulse rounded-full bg-indigo-500" aria-label="Đang xử lý" />
) : ( ) : (
<div <div
className={`h-3 rounded-full transition-all duration-500 ${ className={`h-3 rounded-full transition-all duration-500 ${
job.status === 'FAILED' ? 'bg-rose-500' : job.status === 'FAILED' ? 'bg-rose-500' : job.status === 'CANCELED' ? 'bg-slate-400' : 'bg-indigo-500'
job.status === 'CANCELED' ? 'bg-slate-400' :
'bg-indigo-500'
} ${isActive ? 'animate-pulse' : ''}`} } ${isActive ? 'animate-pulse' : ''}`}
style={{ width: `${progress.percent}%` }} style={{ width: `${progress.percent}%` }}
/> />
...@@ -450,7 +469,7 @@ export const JobDetail: React.FC = () => { ...@@ -450,7 +469,7 @@ export const JobDetail: React.FC = () => {
<div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6"> <div className="bg-white rounded-xl shadow-sm border border-slate-200 p-6">
<h2 className="text-base font-bold text-slate-900 mb-4 flex items-center gap-2"> <h2 className="text-base font-bold text-slate-900 mb-4 flex items-center gap-2">
<FileText className="h-5 w-5 text-slate-400" /> <FileText className="h-5 w-5 text-slate-400" />
Xuất kết quả dữ liệu Xuất kết quả dữ liệu (Data Contract v1)
</h2> </h2>
{isCrawler && ( {isCrawler && (
...@@ -500,13 +519,16 @@ export const JobDetail: React.FC = () => { ...@@ -500,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>
)} )}
...@@ -554,6 +576,66 @@ export const JobDetail: React.FC = () => { ...@@ -554,6 +576,66 @@ export const JobDetail: React.FC = () => {
{activeTab === 'pages' ? ( {activeTab === 'pages' ? (
<> <>
{/* Filter Bar */}
<div className="p-4 bg-slate-50/80 border-b border-slate-100 flex flex-col md:flex-row gap-3 items-center justify-between">
<div className="relative w-full md:w-72">
<Search className="absolute left-3 top-2.5 h-4 w-4 text-slate-400" />
<input
type="text"
placeholder="Tìm URL hoặc tiêu đề..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') void fetchPages(1);
}}
className="w-full pl-9 pr-3 py-1.5 text-xs bg-white border border-slate-300 rounded-lg text-slate-900 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
<div className="flex flex-wrap items-center gap-2 w-full md:w-auto">
<div className="flex items-center gap-1.5 text-xs text-slate-500 font-medium">
<Filter className="h-3.5 w-3.5" /> Lọc:
</div>
<select
value={pageStatusFilter}
onChange={(e) => {
setPageStatusFilter(e.target.value);
void fetchPages(1, false, searchQuery, e.target.value, minQualityFilter);
}}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-700 focus:outline-none"
>
<option value="ALL">Tất cả trạng thái</option>
<option value="SUCCESS">Thành công (SUCCESS)</option>
<option value="FAILED">Thất bại (FAILED)</option>
<option value="BLOCKED">Bị chặn (BLOCKED)</option>
<option value="PENDING">Chờ (PENDING)</option>
</select>
<select
value={minQualityFilter}
onChange={(e) => {
const val = e.target.value === 'ALL' ? 'ALL' : Number(e.target.value);
setMinQualityFilter(val);
void fetchPages(1, false, searchQuery, pageStatusFilter, val);
}}
className="bg-white border border-slate-300 rounded-lg px-2.5 py-1 text-xs text-slate-700 focus:outline-none"
>
<option value="ALL">Tất cả chất lượng</option>
<option value="70">Điểm Quality &ge; 70 (Tốt)</option>
<option value="40">Điểm Quality &ge; 40 (Trung bình)</option>
<option value="0">Tất cả điểm quality</option>
</select>
<button
type="button"
onClick={() => void fetchPages(1)}
className="px-3 py-1 bg-indigo-600 text-white rounded-lg text-xs font-semibold hover:bg-indigo-700 transition-colors"
>
Áp dụng
</button>
</div>
</div>
{pagesError && ( {pagesError && (
<div className="mx-6 mt-4 flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert"> <div className="mx-6 mt-4 flex items-center justify-between gap-3 rounded-lg border border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700" role="alert">
<span>{pagesError}</span> <span>{pagesError}</span>
...@@ -567,7 +649,7 @@ export const JobDetail: React.FC = () => { ...@@ -567,7 +649,7 @@ export const JobDetail: React.FC = () => {
</div> </div>
) : pages.length === 0 ? ( ) : pages.length === 0 ? (
<div className="text-center py-10 text-slate-400 text-sm"> <div className="text-center py-10 text-slate-400 text-sm">
{isActive ? 'Đang chờ Worker bắt đầu cào...' : 'Không có trang nào.'} {isActive ? 'Đang chờ Worker bắt đầu cào...' : 'Không tìm thấy trang phù hợp.'}
</div> </div>
) : ( ) : (
<> <>
...@@ -581,7 +663,7 @@ export const JobDetail: React.FC = () => { ...@@ -581,7 +663,7 @@ export const JobDetail: React.FC = () => {
<th className="px-6 py-3">HTTP Code</th> <th className="px-6 py-3">HTTP Code</th>
<th className="px-6 py-3">Chất lượng</th> <th className="px-6 py-3">Chất lượng</th>
<th className="px-6 py-3">Cảnh báo</th> <th className="px-6 py-3">Cảnh báo</th>
<th className="px-6 py-3">Thời gian cào</th> <th className="px-6 py-3 text-right">Xem trước</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-slate-100 text-slate-700"> <tbody className="divide-y divide-slate-100 text-slate-700">
...@@ -612,7 +694,7 @@ export const JobDetail: React.FC = () => { ...@@ -612,7 +694,7 @@ export const JobDetail: React.FC = () => {
) : '–'} ) : '–'}
</td> </td>
<td className="px-6 py-3"> <td className="px-6 py-3">
<div className="flex flex-col gap-1 items-center"> <div className="flex flex-col gap-1 items-start">
{page.dataQualityScore !== null && page.dataQualityScore !== undefined ? ( {page.dataQualityScore !== null && page.dataQualityScore !== undefined ? (
<span <span
title={`Điểm chất lượng: ${page.dataQualityScore}/100`} title={`Điểm chất lượng: ${page.dataQualityScore}/100`}
...@@ -631,7 +713,7 @@ export const JobDetail: React.FC = () => { ...@@ -631,7 +713,7 @@ export const JobDetail: React.FC = () => {
)} )}
{page.hasSensitiveData && ( {page.hasSensitiveData && (
<span <span
title="Trang này chứa dữ liệu nhạy cảm (email/SĐT/CCCD...)" title="Trang này chứa dữ liệu nhạy cảm"
className="inline-flex items-center gap-0.5 text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-200 px-1.5 py-0.5 rounded" className="inline-flex items-center gap-0.5 text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-200 px-1.5 py-0.5 rounded"
> >
🔒 Nhạy cảm 🔒 Nhạy cảm
...@@ -655,8 +737,16 @@ export const JobDetail: React.FC = () => { ...@@ -655,8 +737,16 @@ export const JobDetail: React.FC = () => {
<span className="text-slate-300 text-xs"></span> <span className="text-slate-300 text-xs"></span>
)} )}
</td> </td>
<td className="px-6 py-3 text-slate-400 text-xs"> <td className="px-6 py-3 text-right">
{page.crawledAt ? new Date(page.crawledAt).toLocaleString('vi-VN') : '–'} <button
onClick={() => {
setSelectedPage(page);
setPreviewTab('clean');
}}
className="inline-flex items-center gap-1 text-xs font-semibold text-indigo-600 hover:text-indigo-900 bg-indigo-50 hover:bg-indigo-100 px-2.5 py-1.5 rounded-lg border border-indigo-100 transition-colors"
>
<Eye className="h-3.5 w-3.5" /> Xem
</button>
</td> </td>
</tr> </tr>
))} ))}
...@@ -676,14 +766,14 @@ export const JobDetail: React.FC = () => { ...@@ -676,14 +766,14 @@ export const JobDetail: React.FC = () => {
disabled={pagesMeta.page <= 1} disabled={pagesMeta.page <= 1}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors" className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
> >
Trước &larr; Trước
</button> </button>
<button <button
onClick={() => fetchPages(pagesMeta.page + 1)} onClick={() => fetchPages(pagesMeta.page + 1)}
disabled={pagesMeta.page >= pagesMeta.totalPages} disabled={pagesMeta.page >= pagesMeta.totalPages}
className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors" className="px-3 py-1.5 rounded-lg border border-slate-200 text-slate-600 hover:bg-white disabled:opacity-40 transition-colors"
> >
Tiếp Tiếp &rarr;
</button> </button>
</div> </div>
</div> </div>
...@@ -795,13 +885,13 @@ export const JobDetail: React.FC = () => { ...@@ -795,13 +885,13 @@ export const JobDetail: React.FC = () => {
{asset.sourceUrl} {asset.sourceUrl}
</a> </a>
) : ( ) : (
<span className="text-slate-300"></span> <span className="text-slate-300">&mdash;</span>
)} )}
</td> </td>
<td className="px-6 py-3 text-xs text-slate-500 max-w-xs"> <td className="px-6 py-3 text-xs text-slate-500 max-w-xs">
{asset.altText && <p className="truncate" title={`Alt: ${asset.altText}`}><strong>Alt:</strong> {asset.altText}</p>} {asset.altText && <p className="truncate" title={`Alt: ${asset.altText}`}><strong>Alt:</strong> {asset.altText}</p>}
{asset.mimeType && <p className="font-mono text-slate-400"><strong>Mime:</strong> {asset.mimeType}</p>} {asset.mimeType && <p className="font-mono text-slate-400"><strong>Mime:</strong> {asset.mimeType}</p>}
{!asset.altText && !asset.mimeType && <span className="text-slate-300"></span>} {!asset.altText && !asset.mimeType && <span className="text-slate-300">&mdash;</span>}
</td> </td>
</tr> </tr>
))} ))}
...@@ -812,6 +902,161 @@ export const JobDetail: React.FC = () => { ...@@ -812,6 +902,161 @@ export const JobDetail: React.FC = () => {
</div> </div>
)} )}
</div> </div>
{/* Page Preview Modal (Data Contract v1) */}
{selectedPage && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-slate-900/50 backdrop-blur-sm">
<div className="bg-white rounded-2xl shadow-2xl border border-slate-200 w-full max-w-4xl max-h-[90vh] flex flex-col overflow-hidden">
{/* Modal Header */}
<div className="px-6 py-4 border-b border-slate-100 flex items-start justify-between bg-slate-50/50">
<div className="pr-4 min-w-0">
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 bg-indigo-50 text-indigo-700 text-xs font-bold rounded">
Data Contract v1 Preview
</span>
{selectedPage.dataQualityScore !== null && selectedPage.dataQualityScore !== undefined && (
<span className="px-2 py-0.5 bg-emerald-50 text-emerald-700 text-xs font-bold rounded-full">
Quality: {selectedPage.dataQualityScore}/100
</span>
)}
</div>
<h3 className="text-lg font-bold text-slate-900 mt-1 truncate" title={selectedPage.title || selectedPage.url}>
{selectedPage.title || selectedPage.url}
</h3>
<a
href={selectedPage.url}
target="_blank"
rel="noreferrer"
className="text-xs text-indigo-600 hover:underline font-mono truncate block"
>
{selectedPage.url}
</a>
</div>
<button
onClick={() => setSelectedPage(null)}
className="text-slate-400 hover:text-slate-600 p-1"
>
<XCircle className="h-6 w-6" />
</button>
</div>
{/* Content Tabs Header */}
<div className="px-6 py-2 bg-slate-100/70 border-b border-slate-200 flex items-center justify-between">
<div className="flex space-x-2">
<button
onClick={() => setPreviewTab('clean')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'clean'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Sparkles className="h-3.5 w-3.5" />
Main Content (AI Clean)
</button>
<button
onClick={() => setPreviewTab('raw')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'raw'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<Code className="h-3.5 w-3.5" />
Raw Markdown
</button>
<button
onClick={() => setPreviewTab('text')}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-semibold transition-colors ${
previewTab === 'text'
? 'bg-indigo-600 text-white shadow-sm'
: 'bg-white text-slate-600 hover:bg-slate-50'
}`}
>
<FileText className="h-3.5 w-3.5" />
Clean Text
</button>
</div>
<button
onClick={() => {
const content =
previewTab === 'clean'
? selectedPage.mainContent
: previewTab === 'raw'
? selectedPage.rawMarkdown
: selectedPage.cleanText;
if (content) copyToClipboard(content);
}}
className="flex items-center gap-1 px-3 py-1 bg-white border border-slate-300 rounded-md text-xs font-medium text-slate-700 hover:bg-slate-50"
>
{copied ? <Check className="h-3.5 w-3.5 text-emerald-600" /> : <Copy className="h-3.5 w-3.5 text-slate-400" />}
{copied ? 'Đã chép' : 'Sao chép'}
</button>
</div>
{/* Modal Body */}
<div className="p-6 overflow-y-auto flex-1 space-y-4 font-sans">
{/* Meta Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 p-3 bg-slate-50 rounded-xl text-xs">
<div>
<span className="text-slate-400 font-medium block">Số từ (Word Count)</span>
<span className="font-semibold text-slate-800">{selectedPage.wordCount ?? '—'}</span>
</div>
<div>
<span className="text-slate-400 font-medium block">Content Hash</span>
<span className="font-mono text-slate-600 truncate block" title={selectedPage.contentHash || ''}>
{selectedPage.contentHash ? `${selectedPage.contentHash.substring(0, 12)}...` : '—'}
</span>
</div>
<div>
<span className="text-slate-400 font-medium block">Cảnh báo</span>
<span className="font-semibold text-slate-800">
{selectedPage.warnings && selectedPage.warnings.length > 0
? selectedPage.warnings.join(', ')
: 'Không có'}
</span>
</div>
<div>
<span className="text-slate-400 font-medium block">Thời gian cào</span>
<span className="text-slate-700">
{selectedPage.crawledAt ? new Date(selectedPage.crawledAt).toLocaleString('vi-VN') : '—'}
</span>
</div>
</div>
{/* Main Content Area */}
<div className="border border-slate-200 rounded-xl p-4 bg-slate-900 text-slate-100 font-mono text-xs overflow-x-auto min-h-[300px] max-h-[500px]">
{previewTab === 'clean' && (
<pre className="whitespace-pre-wrap font-mono break-words leading-relaxed text-emerald-400">
{selectedPage.mainContent || <span className="text-slate-500 italic">Chưa có nội dung mainContent.</span>}
</pre>
)}
{previewTab === 'raw' && (
<pre className="whitespace-pre-wrap font-mono break-words leading-relaxed text-slate-200">
{selectedPage.rawMarkdown || <span className="text-slate-500 italic">Chưa có nội dung rawMarkdown.</span>}
</pre>
)}
{previewTab === 'text' && (
<pre className="whitespace-pre-wrap font-sans break-words leading-relaxed text-slate-300">
{selectedPage.cleanText || <span className="text-slate-500 italic">Chưa có nội dung cleanText.</span>}
</pre>
)}
</div>
</div>
{/* Modal Footer */}
<div className="px-6 py-3 bg-slate-50 border-t border-slate-100 flex justify-end">
<button
onClick={() => setSelectedPage(null)}
className="px-4 py-2 bg-slate-200 text-slate-700 rounded-lg hover:bg-slate-300 text-xs font-semibold transition-colors"
>
Đóng
</button>
</div>
</div>
</div>
)}
</div> </div>
); );
}; };
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { Database, Lock, Mail, Loader2 } from 'lucide-react'; import { Database, Mail, Loader2 } from 'lucide-react';
import { api } from '../services/api';
import { PasswordInput } from '../components/PasswordInput';
export const Login: React.FC = () => { export const Login: React.FC = () => {
const { login } = useAuth(); const { login } = useAuth();
...@@ -10,22 +12,62 @@ export const Login: React.FC = () => { ...@@ -10,22 +12,62 @@ export const Login: React.FC = () => {
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [error, setError] = useState(''); const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [canResendVerification, setCanResendVerification] = useState(false);
const [resending, setResending] = useState(false);
const [resendMessage, setResendMessage] = useState('');
const [resendStatus, setResendStatus] = useState<'success' | 'error' | null>(null);
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setError('');
setResendMessage('');
setResendStatus(null);
setCanResendVerification(false);
setSubmitting(true); setSubmitting(true);
try { try {
await login(email, password); await login(email, password);
navigate('/'); navigate('/');
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
setError(err.response?.data?.message || 'Đăng nhập thất bại. Vui lòng kiểm tra lại thông tin.'); const code = err.response?.data?.code;
const isInactiveAccount = code === 'USER_INACTIVE' || err.response?.status === 403;
setCanResendVerification(isInactiveAccount);
setError(
isInactiveAccount
? 'Tài khoản chưa được xác thực hoặc đã bị khóa. Bạn có thể gửi lại email xác thực bên dưới.'
: code === 'INVALID_CREDENTIALS'
? 'Tài khoản hoặc mật khẩu không chính xác.'
: err.response?.data?.message || 'Đăng nhập thất bại. Vui lòng kiểm tra lại thông tin.',
);
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const handleResendVerification = async () => {
if (!email.trim()) {
setResendStatus('error');
setResendMessage('Vui lòng nhập địa chỉ email trước khi gửi lại email xác thực.');
return;
}
setResending(true);
setResendMessage('');
setResendStatus(null);
try {
const response = await api.post('/auth/resend-verification', { email });
setResendStatus('success');
setResendMessage(
response.data.message || 'Nếu tài khoản chưa được xác thực, email xác thực đã được gửi lại.',
);
} catch (err: any) {
setResendStatus('error');
setResendMessage(err.response?.data?.message || 'Không thể gửi lại email xác thực. Vui lòng thử lại sau.');
} finally {
setResending(false);
}
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12 sm:px-6 lg:px-8"> <div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8 rounded-2xl bg-white p-8 shadow-xl border border-slate-100"> <div className="w-full max-w-md space-y-8 rounded-2xl bg-white p-8 shadow-xl border border-slate-100">
...@@ -42,8 +84,32 @@ export const Login: React.FC = () => { ...@@ -42,8 +84,32 @@ export const Login: React.FC = () => {
</div> </div>
{error && ( {error && (
<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-600 border border-rose-100"> <div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-700 border border-rose-100" role="alert">
{error} <p>{error}</p>
{canResendVerification && (
<button
type="button"
onClick={() => void handleResendVerification()}
disabled={resending}
className="mt-3 inline-flex min-h-11 items-center font-semibold text-indigo-700 hover:text-indigo-900 disabled:cursor-not-allowed disabled:opacity-60"
>
{resending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Gửi lại email xác thực
</button>
)}
</div>
)}
{resendMessage && (
<div
className={`rounded-lg border p-4 text-sm ${
resendStatus === 'error'
? 'border-rose-100 bg-rose-50 text-rose-700'
: 'border-emerald-100 bg-emerald-50 text-emerald-700'
}`}
role={resendStatus === 'error' ? 'alert' : undefined}
aria-live="polite"
>
{resendMessage}
</div> </div>
)} )}
...@@ -69,34 +135,25 @@ export const Login: React.FC = () => { ...@@ -69,34 +135,25 @@ export const Login: React.FC = () => {
placeholder="admin@crawl.local" placeholder="admin@crawl.local"
/> />
</div> </div>
</div>
<div>
<PasswordInput
id="password"
name="password"
label="Mật khẩu"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
<div className="mt-2 text-right"> <div className="mt-2 text-right">
<Link to="/forgot-password" className="text-sm font-medium text-indigo-600 hover:text-indigo-500"> <Link to="/forgot-password" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
Quên mật khẩu? Quên mật khẩu?
</Link> </Link>
</div> </div>
</div> </div>
<div>
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1">
Mật khẩu
</label>
<div className="relative">
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400">
<Lock className="h-5 w-5" />
</div>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-3 text-slate-900 placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
placeholder="••••••••"
/>
</div>
</div>
</div> </div>
<div> <div>
......
...@@ -4,7 +4,13 @@ import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react'; ...@@ -4,7 +4,13 @@ import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react';
interface AuditLog { interface AuditLog {
id: string; id: string;
userId: string; userId: string | null;
user: {
id: string;
email: string;
fullName: string | null;
role: string;
} | null;
action: string; action: string;
ipAddress: string | null; ipAddress: string | null;
userAgent: string | null; userAgent: string | null;
...@@ -27,11 +33,43 @@ const ACTION_COLORS: Record<string, string> = { ...@@ -27,11 +33,43 @@ const ACTION_COLORS: Record<string, string> = {
LOGIN: 'bg-slate-100 text-slate-600', LOGIN: 'bg-slate-100 text-slate-600',
LOGOUT: 'bg-slate-100 text-slate-500', LOGOUT: 'bg-slate-100 text-slate-500',
REGISTER: 'bg-violet-100 text-violet-700', REGISTER: 'bg-violet-100 text-violet-700',
CREATE_USER: 'bg-emerald-100 text-emerald-700', ADMIN_CREATE_USER: 'bg-emerald-100 text-emerald-700',
DELETE_USER: 'bg-rose-100 text-rose-700', ADMIN_DELETE_USER: 'bg-rose-100 text-rose-700',
UPDATE_USER: 'bg-indigo-100 text-indigo-700', ADMIN_UPDATE_USER: 'bg-indigo-100 text-indigo-700',
UPDATE_ME: 'bg-indigo-100 text-indigo-700',
CHANGE_PASSWORD: 'bg-amber-100 text-amber-700',
FORGOT_PASSWORD: 'bg-sky-100 text-sky-700',
RESET_PASSWORD: 'bg-sky-100 text-sky-700',
VERIFY_EMAIL: 'bg-teal-100 text-teal-700',
RESEND_VERIFICATION: 'bg-teal-100 text-teal-700',
CREATE_API_KEY: 'bg-emerald-100 text-emerald-700',
UPDATE_API_KEY_STATUS: 'bg-indigo-100 text-indigo-700',
REVOKE_API_KEY: 'bg-rose-100 text-rose-700',
}; };
const ACTION_OPTIONS = [
'LOGIN',
'LOGOUT',
'REGISTER',
'UPDATE_ME',
'CHANGE_PASSWORD',
'FORGOT_PASSWORD',
'RESET_PASSWORD',
'VERIFY_EMAIL',
'RESEND_VERIFICATION',
'CREATE_JOB',
'CANCEL_JOB',
'DOWNLOAD_EXPORT',
'ADMIN_CREATE_USER',
'ADMIN_UPDATE_USER',
'ADMIN_DELETE_USER',
'CREATE_API_KEY',
'UPDATE_API_KEY_STATUS',
'REVOKE_API_KEY',
'CREATE_WEBHOOK_CONFIG',
'DELETE_WEBHOOK_CONFIG',
];
export const Logs: React.FC = () => { export const Logs: React.FC = () => {
const [logs, setLogs] = useState<AuditLog[]>([]); const [logs, setLogs] = useState<AuditLog[]>([]);
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 }); const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 });
...@@ -139,14 +177,21 @@ export const Logs: React.FC = () => { ...@@ -139,14 +177,21 @@ export const Logs: React.FC = () => {
className="pl-9 pr-3 py-2 rounded-lg border border-slate-300 text-sm w-64 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" className="pl-9 pr-3 py-2 rounded-lg border border-slate-300 text-sm w-64 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/> />
</div> </div>
<div className="relative"> <div>
<input <label htmlFor="action-filter" className="mb-1 block text-xs font-medium text-slate-600">
type="text" Hành động
placeholder="Lọc theo Action (CREATE_JOB...)" </label>
<select
id="action-filter"
value={actionInput} value={actionInput}
onChange={(e) => setActionInput(e.target.value)} onChange={(e) => setActionInput(e.target.value)}
className="px-3 py-2 rounded-lg border border-slate-300 text-sm w-60 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" className="w-60 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/> >
<option value="">Tất cả hành động</option>
{ACTION_OPTIONS.map((action) => (
<option key={action} value={action}>{action}</option>
))}
</select>
</div> </div>
<button <button
type="submit" type="submit"
...@@ -183,7 +228,7 @@ export const Logs: React.FC = () => { ...@@ -183,7 +228,7 @@ export const Logs: React.FC = () => {
<tr> <tr>
<th className="px-6 py-4">Thời gian</th> <th className="px-6 py-4">Thời gian</th>
<th className="px-6 py-4">Hành động</th> <th className="px-6 py-4">Hành động</th>
<th className="px-6 py-4">User ID</th> <th className="px-6 py-4">Người dùng</th>
<th className="px-6 py-4">IP Address</th> <th className="px-6 py-4">IP Address</th>
<th className="px-6 py-4">Chi tiết</th> <th className="px-6 py-4">Chi tiết</th>
</tr> </tr>
...@@ -197,9 +242,21 @@ export const Logs: React.FC = () => { ...@@ -197,9 +242,21 @@ export const Logs: React.FC = () => {
</td> </td>
<td className="px-6 py-3">{actionBadge(log.action)}</td> <td className="px-6 py-3">{actionBadge(log.action)}</td>
<td className="px-6 py-3"> <td className="px-6 py-3">
<span className="font-mono text-xs text-slate-500 truncate max-w-[120px] block" title={log.userId}> {log.user ? (
{log.userId} <div className="min-w-64">
</span> <p className="text-sm font-medium text-slate-800">
{log.user.fullName || log.user.email}
</p>
<p className="text-xs text-indigo-600">{log.user.email}</p>
<p className="mt-1 break-all font-mono text-xs text-slate-500">
ID: {log.userId}
</p>
</div>
) : (
<span className="text-xs text-slate-400">
{log.userId ? <span className="break-all font-mono">{log.userId}</span> : 'Hệ thống / Khách'}
</span>
)}
</td> </td>
<td className="px-6 py-3 text-xs font-mono text-slate-500"> <td className="px-6 py-3 text-xs font-mono text-slate-500">
{log.ipAddress || '—'} {log.ipAddress || '—'}
......
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { useEffect, useState } from 'react'; 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';
import { PasswordInput } from '../components/PasswordInput';
import { getApiErrorMessages } from '../utils/apiError';
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('');
...@@ -17,14 +19,49 @@ export const Profile: React.FC = () => { ...@@ -17,14 +19,49 @@ export const Profile: React.FC = () => {
const handleSubmit = async (event: React.FormEvent) => { const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
setError(''); setMessage(''); setError(''); setMessage('');
if (password && password !== confirmPassword) return setError('Mật khẩu xác nhận không khớp.'); const normalizedFullName = fullName.trim();
const profileChanged = normalizedFullName !== (user?.fullName || '');
const passwordChanged = Boolean(oldPassword || password || confirmPassword);
if (!profileChanged && !passwordChanged) {
setError('Bạn chưa thay đổi thông tin nào.');
return;
}
if (passwordChanged && !oldPassword) {
setError('Vui lòng nhập mật khẩu hiện tại.');
return;
}
if (passwordChanged && !password) {
setError('Vui lòng nhập mật khẩu mới.');
return;
}
if (password !== confirmPassword) {
setError('Mật khẩu xác nhận không khớp.');
return;
}
setSubmitting(true); setSubmitting(true);
try { try {
await updateProfile({ fullName: fullName.trim(), ...(password ? { oldPassword, password } : {}) }); if (passwordChanged) {
await changePassword({
currentPassword: oldPassword,
newPassword: password,
confirmPassword,
});
}
if (profileChanged) {
await updateProfile({ fullName: normalizedFullName });
}
setOldPassword(''); setPassword(''); setConfirmPassword(''); setOldPassword(''); setPassword(''); setConfirmPassword('');
setMessage('Cập nhật hồ sơ thành công.'); setMessage(
} catch (err: any) { profileChanged && passwordChanged
setError(err.response?.data?.message || 'Không thể cập nhật hồ sơ.'); ? 'Cập nhật hồ sơ và mật khẩu thành công.'
: passwordChanged
? 'Đổi mật khẩu thành công.'
: 'Cập nhật hồ sơ thành công.',
);
} catch (err: unknown) {
setError(getApiErrorMessages(err, 'Không thể cập nhật hồ sơ.').join('\n'));
} finally { setSubmitting(false); } } finally { setSubmitting(false); }
}; };
...@@ -84,8 +121,8 @@ export const Profile: React.FC = () => { ...@@ -84,8 +121,8 @@ export const Profile: React.FC = () => {
<h2 className="font-bold text-slate-900">Cập nhật thông tin</h2> <h2 className="font-bold text-slate-900">Cập nhật thông tin</h2>
<p className="text-sm text-slate-500">Đổi tên hiển thị hoặc mật khẩu tài khoản.</p> <p className="text-sm text-slate-500">Đổi tên hiển thị hoặc mật khẩu tài khoản.</p>
</div> </div>
{error && <div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{error}</div>} {error && <div className="whitespace-pre-line rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700" role="alert">{error}</div>}
{message && <div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">{message}</div>} {message && <div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700" aria-live="polite">{message}</div>}
<div> <div>
<label className="mb-1 block text-sm font-medium text-slate-700">Họ và tên</label> <label className="mb-1 block text-sm font-medium text-slate-700">Họ và tên</label>
<input value={fullName} onChange={(e) => setFullName(e.target.value)} className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" /> <input value={fullName} onChange={(e) => setFullName(e.target.value)} className="w-full rounded-lg border border-slate-300 px-3 py-2 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500" />
...@@ -93,9 +130,31 @@ export const Profile: React.FC = () => { ...@@ -93,9 +130,31 @@ export const Profile: React.FC = () => {
<div className="border-t border-slate-100 pt-5"> <div className="border-t border-slate-100 pt-5">
<div className="mb-3 flex items-center gap-2 font-semibold text-slate-800"><KeyRound className="h-4 w-4" /> Đổi mật khẩu</div> <div className="mb-3 flex items-center gap-2 font-semibold text-slate-800"><KeyRound className="h-4 w-4" /> Đổi mật khẩu</div>
<div className="grid gap-4 sm:grid-cols-3"> <div className="grid gap-4 sm:grid-cols-3">
<input type="password" autoComplete="current-password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} placeholder="Mật khẩu hiện tại" required={Boolean(password)} className="rounded-lg border border-slate-300 px-3 py-2 text-sm" /> <PasswordInput
<input type="password" autoComplete="new-password" value={password} onChange={(e) => setPassword(e.target.value)} placeholder="Mật khẩu mới" minLength={8} className="rounded-lg border border-slate-300 px-3 py-2 text-sm" /> id="current-password"
<input type="password" autoComplete="new-password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} placeholder="Xác nhận mật khẩu" required={Boolean(password)} className="rounded-lg border border-slate-300 px-3 py-2 text-sm" /> label="Mật khẩu hiện tại"
autoComplete="current-password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
placeholder="••••••••"
/>
<PasswordInput
id="profile-new-password"
label="Mật khẩu mới"
autoComplete="new-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
minLength={8}
/>
<PasswordInput
id="profile-confirm-password"
label="Xác nhận mật khẩu"
autoComplete="new-password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
/>
</div> </div>
<p className="mt-2 text-xs text-slate-500">Mật khẩu mới cần ít nhất 8 ký tự, gồm chữ hoa, chữ thường, số và ký tự đặc biệt.</p> <p className="mt-2 text-xs text-slate-500">Mật khẩu mới cần ít nhất 8 ký tự, gồm chữ hoa, chữ thường, số và ký tự đặc biệt.</p>
</div> </div>
......
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/auth'; import { useAuth } from '../context/auth';
import { Database, Lock, Mail, Loader2, User } from 'lucide-react'; import { Database, Mail, Loader2, User } from 'lucide-react';
import { PasswordInput } from '../components/PasswordInput';
import { getApiErrorMessages } from '../utils/apiError';
import { api } from '../services/api';
export const Register: React.FC = () => { export const Register: React.FC = () => {
const { register } = useAuth(); const { register } = useAuth();
...@@ -10,16 +13,19 @@ export const Register: React.FC = () => { ...@@ -10,16 +13,19 @@ export const Register: React.FC = () => {
const [fullName, setFullName] = useState(''); const [fullName, setFullName] = useState('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState(''); const [errors, setErrors] = useState<string[]>([]);
const [success, setSuccess] = useState(false); const [success, setSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [resending, setResending] = useState(false);
const [resendMessage, setResendMessage] = useState('');
const [resendError, setResendError] = useState('');
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setError(''); setErrors([]);
if (password !== confirmPassword) { if (password !== confirmPassword) {
setError('Mật khẩu nhập lại không khớp.'); setErrors(['Mật khẩu xác nhận không khớp.']);
return; return;
} }
...@@ -27,17 +33,32 @@ export const Register: React.FC = () => { ...@@ -27,17 +33,32 @@ export const Register: React.FC = () => {
try { try {
await register(email, password, fullName); await register(email, password, fullName);
setSuccess(true); setSuccess(true);
setTimeout(() => {
navigate('/login');
}, 2000);
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
setError(err.response?.data?.message || 'Đăng ký thất bại. Vui lòng thử lại.'); setErrors(getApiErrorMessages(err, 'Đăng ký thất bại. Vui lòng thử lại.'));
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
const handleResendVerification = async () => {
setResending(true);
setResendMessage('');
setResendError('');
try {
const response = await api.post('/auth/resend-verification', { email });
setResendMessage(
response.data.message || 'Email xác thực đã được gửi lại. Vui lòng kiểm tra cả thư rác.',
);
} catch (err: any) {
setResendError(
err.response?.data?.message || 'Không thể gửi lại email xác thực. Vui lòng thử lại sau.',
);
} finally {
setResending(false);
}
};
return ( return (
<div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12 sm:px-6 lg:px-8"> <div className="flex min-h-screen items-center justify-center bg-slate-50 px-4 py-12 sm:px-6 lg:px-8">
<div className="w-full max-w-md space-y-8 rounded-2xl bg-white p-8 shadow-xl border border-slate-100"> <div className="w-full max-w-md space-y-8 rounded-2xl bg-white p-8 shadow-xl border border-slate-100">
...@@ -53,19 +74,55 @@ export const Register: React.FC = () => { ...@@ -53,19 +74,55 @@ export const Register: React.FC = () => {
</p> </p>
</div> </div>
{error && ( {errors.length > 0 && (
<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-600 border border-rose-100"> <div className="rounded-lg border border-rose-100 bg-rose-50 p-4 text-sm text-rose-700" role="alert">
{error} <p className="font-semibold">Không thể đăng ký:</p>
<ul className="mt-2 list-disc space-y-1 pl-5">
{errors.map((message) => <li key={message}>{message}</li>)}
</ul>
</div> </div>
)} )}
{success && ( {success && (
<div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-600 border border-emerald-100"> <div className="space-y-4 rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700" aria-live="polite">
Đăng ký thành công! Đang chuyển hướng về trang đăng nhập... <div>
<p className="font-semibold">Đăng ký thành công.</p>
<p className="mt-1">
Email xác thực đã được gửi tới <strong>{email}</strong>. Vui lòng kiểm tra hộp thư và thư rác.
</p>
</div>
{resendMessage && (
<p className="rounded-md bg-emerald-100 px-3 py-2 text-emerald-800">{resendMessage}</p>
)}
{resendError && (
<p className="rounded-md border border-rose-200 bg-rose-50 px-3 py-2 text-rose-700" role="alert">
{resendError}
</p>
)}
<div className="flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={() => void handleResendVerification()}
disabled={resending}
className="inline-flex min-h-11 flex-1 items-center justify-center rounded-lg border border-indigo-200 bg-white px-4 font-semibold text-indigo-700 hover:bg-indigo-50 focus:outline-none focus:ring-2 focus:ring-indigo-500 disabled:cursor-not-allowed disabled:opacity-60"
>
{resending && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Gửi lại email xác thực
</button>
<button
type="button"
onClick={() => navigate('/login')}
className="min-h-11 flex-1 rounded-lg bg-indigo-600 px-4 font-semibold text-white hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-500"
>
Đi đến đăng nhập
</button>
</div>
</div> </div>
)} )}
<form className="mt-8 space-y-6" onSubmit={handleSubmit}> {!success && <form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4 rounded-md shadow-sm"> <div className="space-y-4 rounded-md shadow-sm">
<div> <div>
<label htmlFor="full-name" className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label> <label htmlFor="full-name" className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label>
...@@ -97,47 +154,28 @@ export const Register: React.FC = () => { ...@@ -97,47 +154,28 @@ export const Register: React.FC = () => {
</div> </div>
</div> </div>
<div> <PasswordInput
<label htmlFor="password" className="block text-sm font-medium text-slate-700 mb-1"> id="password"
Mật khẩu name="password"
</label> label="Mật khẩu"
<div className="relative"> autoComplete="new-password"
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400"> required
<Lock className="h-5 w-5" /> minLength={8}
</div> value={password}
<input onChange={(e) => setPassword(e.target.value)}
id="password" placeholder="••••••••"
name="password" />
type="password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="block w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-3 text-slate-900 placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
placeholder="••••••••"
/>
</div>
</div>
<div> <PasswordInput
<label htmlFor="confirm-password" className="block text-sm font-medium text-slate-700 mb-1"> id="confirm-password"
Xác nhận Mật khẩu name="confirmPassword"
</label> label="Xác nhận mật khẩu"
<div className="relative"> autoComplete="new-password"
<div className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-slate-400"> required
<Lock className="h-5 w-5" /> value={confirmPassword}
</div> onChange={(e) => setConfirmPassword(e.target.value)}
<input placeholder="••••••••"
id="confirm-password" />
name="confirmPassword"
type="password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="block w-full rounded-lg border border-slate-300 bg-white py-2.5 pl-10 pr-3 text-slate-900 placeholder-slate-400 focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500 sm:text-sm"
placeholder="••••••••"
/>
</div>
</div>
</div> </div>
<div> <div>
...@@ -153,7 +191,7 @@ export const Register: React.FC = () => { ...@@ -153,7 +191,7 @@ export const Register: React.FC = () => {
)} )}
</button> </button>
</div> </div>
</form> </form>}
<div className="text-center text-sm text-slate-500"> <div className="text-center text-sm text-slate-500">
Đã có tài khoản?{' '} Đã có tài khoản?{' '}
......
...@@ -3,11 +3,83 @@ import { Link, useSearchParams } from 'react-router-dom'; ...@@ -3,11 +3,83 @@ import { Link, useSearchParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { api } from '../services/api'; import { api } from '../services/api';
import { AuthCard } from './ForgotPassword'; import { AuthCard } from './ForgotPassword';
import { PasswordInput } from '../components/PasswordInput';
import { getApiErrorMessages } from '../utils/apiError';
export const ResetPassword = () => { export const ResetPassword = () => {
const [params] = useSearchParams(); const token = params.get('token') || ''; const [params] = useSearchParams();
const [password,setPassword]=useState(''); const [confirm,setConfirm]=useState(''); const token = params.get('token') || '';
const [loading,setLoading]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState(false); const [password, setPassword] = useState('');
const submit=async(e:React.FormEvent)=>{e.preventDefault();setError('');if(password!==confirm)return setError('Mật khẩu xác nhận không khớp.');setLoading(true);try{await api.post('/auth/reset-password',{token,password});setSuccess(true);}catch(err:any){setError(err.response?.data?.message||'Liên kết không hợp lệ hoặc đã hết hạn.');}finally{setLoading(false);}}; const [confirm, setConfirm] = useState('');
return <AuthCard title="Đặt lại mật khẩu" subtitle="Tạo mật khẩu mới cho tài khoản của bạn.">{!token?<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-700">Thiếu mã đặt lại mật khẩu.</div>:success?<div className="space-y-4 text-center"><div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-700">Đổi mật khẩu thành công.</div><Link to="/login" className="font-semibold text-indigo-600">Đăng nhập ngay</Link></div>:<form onSubmit={submit} className="space-y-4">{error&&<div className="rounded-lg bg-rose-50 p-3 text-sm text-rose-700">{error}</div>}<input type="password" required minLength={8} value={password} onChange={e=>setPassword(e.target.value)} placeholder="Mật khẩu mới" className="w-full rounded-lg border border-slate-300 px-3 py-2.5"/><input type="password" required value={confirm} onChange={e=>setConfirm(e.target.value)} placeholder="Xác nhận mật khẩu" className="w-full rounded-lg border border-slate-300 px-3 py-2.5"/><p className="text-xs text-slate-500">Ít nhất 8 ký tự, gồm chữ hoa, chữ thường, số và ký tự đặc biệt.</p><button disabled={loading} className="flex w-full justify-center rounded-lg bg-indigo-600 py-3 font-semibold text-white">{loading?<Loader2 className="h-5 w-5 animate-spin"/>:'Đặt lại mật khẩu'}</button></form>}</AuthCard>; const [loading, setLoading] = useState(false);
}; const [errors, setErrors] = useState<string[]>([]);
\ No newline at end of file const [success, setSuccess] = useState(false);
const submit = async (event: React.FormEvent) => {
event.preventDefault();
setErrors([]);
if (password !== confirm) {
setErrors(['Mật khẩu xác nhận không khớp.']);
return;
}
setLoading(true);
try {
await api.post('/auth/reset-password', { token, password });
setSuccess(true);
} catch (error: unknown) {
setErrors(getApiErrorMessages(error, 'Liên kết không hợp lệ hoặc đã hết hạn.'));
} finally {
setLoading(false);
}
};
return (
<AuthCard title="Đặt lại mật khẩu" subtitle="Tạo mật khẩu mới cho tài khoản của bạn.">
{!token ? (
<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-700">Thiếu mã đặt lại mật khẩu.</div>
) : success ? (
<div className="space-y-4 text-center">
<div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-700">Đổi mật khẩu thành công.</div>
<Link to="/login" className="font-semibold text-indigo-600">Đăng nhập ngay</Link>
</div>
) : (
<form onSubmit={submit} className="space-y-4">
{errors.length > 0 && (
<div className="rounded-lg bg-rose-50 p-3 text-sm text-rose-700" role="alert">
<ul className="list-disc space-y-1 pl-5">
{errors.map((message) => <li key={message}>{message}</li>)}
</ul>
</div>
)}
<PasswordInput
id="new-password"
label="Mật khẩu mới"
required
minLength={8}
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="••••••••"
/>
<PasswordInput
id="confirm-new-password"
label="Xác nhận mật khẩu"
required
autoComplete="new-password"
value={confirm}
onChange={(event) => setConfirm(event.target.value)}
placeholder="••••••••"
/>
<p className="text-xs text-slate-500">Ít nhất 8 ký tự, gồm chữ hoa, chữ thường, số và ký tự đặc biệt.</p>
<button
disabled={loading}
className="flex w-full min-h-11 items-center justify-center rounded-lg bg-indigo-600 py-3 font-semibold text-white disabled:opacity-60"
>
{loading ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Đặt lại mật khẩu'}
</button>
</form>
)}
</AuthCard>
);
};
...@@ -12,6 +12,8 @@ import { ...@@ -12,6 +12,8 @@ import {
ShieldCheck, ShieldCheck,
User, User,
} from 'lucide-react'; } from 'lucide-react';
import { PasswordInput } from '../components/PasswordInput';
import { getApiErrorMessages } from '../utils/apiError';
interface UserItem { interface UserItem {
id: string; id: string;
...@@ -119,8 +121,8 @@ export const Users: React.FC = () => { ...@@ -119,8 +121,8 @@ export const Users: React.FC = () => {
setShowCreate(false); setShowCreate(false);
setCreateForm(emptyCreate); setCreateForm(emptyCreate);
fetchUsers(1, search, true); fetchUsers(1, search, true);
} catch (err: any) { } catch (err: unknown) {
setCreateError(err.response?.data?.message || 'Tạo user thất bại.'); setCreateError(getApiErrorMessages(err, 'Tạo người dùng thất bại.').join('\n'));
} finally { } finally {
setCreating(false); setCreating(false);
} }
...@@ -145,8 +147,8 @@ export const Users: React.FC = () => { ...@@ -145,8 +147,8 @@ export const Users: React.FC = () => {
}); });
setEditUser(null); setEditUser(null);
fetchUsers(meta.page, search, true); fetchUsers(meta.page, search, true);
} catch (err: any) { } catch (err: unknown) {
setEditError(err.response?.data?.message || 'Cập nhật thất bại.'); setEditError(getApiErrorMessages(err, 'Cập nhật thất bại.').join('\n'));
} finally { } finally {
setEditing(false); setEditing(false);
} }
...@@ -360,17 +362,17 @@ export const Users: React.FC = () => { ...@@ -360,17 +362,17 @@ export const Users: React.FC = () => {
placeholder="user@example.com" placeholder="user@example.com"
/> />
</div> </div>
<div> <PasswordInput
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu *</label> id="create-user-password"
<input label="Mật khẩu *"
type="password" required
required minLength={8}
value={createForm.password} autoComplete="new-password"
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })} value={createForm.password}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500" onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
placeholder="Tối thiểu 8 ký tự" placeholder="Tối thiểu 8 ký tự"
/> inputClassName="focus:border-violet-500 focus:ring-violet-500"
</div> />
<div> <div>
<label className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label> <label className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label>
<input <input
...@@ -443,12 +445,18 @@ export const Users: React.FC = () => { ...@@ -443,12 +445,18 @@ export const Users: React.FC = () => {
<select <select
value={editForm.role} value={editForm.role}
onChange={(e) => setEditForm({ ...editForm, role: e.target.value as any })} onChange={(e) => setEditForm({ ...editForm, role: e.target.value as any })}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500" disabled={editUser.role === 'ADMIN'}
className="block w-full rounded-lg border border-slate-300 py-2 px-3 text-sm focus:border-violet-500 focus:outline-none focus:ring-1 focus:ring-violet-500 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-500"
> >
<option value="CRAWLER_USER">CRAWLER_USER</option> <option value="CRAWLER_USER">CRAWLER_USER</option>
<option value="VIEWER">VIEWER</option> <option value="VIEWER">VIEWER</option>
<option value="ADMIN">ADMIN</option> <option value="ADMIN">ADMIN</option>
</select> </select>
{editUser.role === 'ADMIN' && (
<p className="mt-1.5 text-xs text-slate-500">
Không thể thay đổi vai trò của tài khoản đã có quyền Admin.
</p>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
......
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 interface ApiKeyRecord {
id: string;
userId: string;
name: string;
keyPrefix: string;
isActive: boolean;
expiresAt: string | null;
lastUsedAt: string | null;
createdAt: string;
updatedAt: string;
}
export interface CreatedApiKey extends ApiKeyRecord {
rawKey: string;
}
interface ApiResponse<T> {
success: boolean;
data: T;
}
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 +91,33 @@ api.interceptors.response.use( ...@@ -106,11 +91,33 @@ api.interceptors.response.use(
} }
); );
function handleLogout() { export const getApiUrl = (path: string) =>
`${API_BASE_URL}/${path.replace(/^\/+/, '')}`;
export const apiKeysApi = {
async list(): Promise<ApiKeyRecord[]> {
const response = await api.get<ApiResponse<ApiKeyRecord[]>>('/api-keys');
return response.data.data;
},
async create(input: { name: string; expiresAt: string | null }): Promise<CreatedApiKey> {
const response = await api.post<ApiResponse<CreatedApiKey>>('/api-keys', input);
return response.data.data;
},
async setActive(id: string, isActive: boolean): Promise<ApiKeyRecord> {
const response = await api.patch<ApiResponse<ApiKeyRecord>>(`/api-keys/${id}`, { isActive });
return response.data.data;
},
async revoke(id: string): Promise<void> {
await api.delete(`/api-keys/${id}`);
},
};
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';
}
} }
const CODE_MESSAGES: Record<string, string> = {
INVALID_CREDENTIALS: 'Tài khoản hoặc mật khẩu không chính xác.',
USER_INACTIVE: 'Tài khoản chưa được xác thực hoặc đã bị khóa. Vui lòng kiểm tra email xác thực.',
DUPLICATE_ENTRY: 'Email này đã được sử dụng.',
MAIL_DELIVERY_FAILED: 'Không thể gửi email xác thực. Vui lòng thử lại sau.',
RATE_LIMIT_EXCEEDED: 'Bạn thao tác quá nhanh. Vui lòng thử lại sau.',
TOKEN_EXPIRED: 'Liên kết đã hết hạn. Vui lòng yêu cầu một liên kết mới.',
TOKEN_INVALID: 'Liên kết không hợp lệ. Vui lòng kiểm tra lại.',
};
const MESSAGE_TRANSLATIONS: Record<string, string> = {
'Invalid email format': 'Email không đúng định dạng.',
'Password must be at least 8 characters': 'Mật khẩu phải có ít nhất 8 ký tự.',
'Password must contain at least one lowercase letter': 'Mật khẩu phải có ít nhất một chữ thường.',
'Password must contain at least one uppercase letter': 'Mật khẩu phải có ít nhất một chữ hoa.',
'Password must contain at least one number': 'Mật khẩu phải có ít nhất một chữ số.',
'Password must contain at least one special character': 'Mật khẩu phải có ít nhất một ký tự đặc biệt.',
'API Key expiration must be in the future': 'Thời điểm hết hạn của API Key phải ở trong tương lai.',
'Invalid ISO datetime format for expiration': 'Thời điểm hết hạn của API Key không đúng định dạng.',
'API Key status is required': 'Vui lòng chọn trạng thái cho API Key.',
'API Key status must be a boolean': 'Trạng thái API Key không hợp lệ.',
'API key not found': 'Không tìm thấy API Key.',
};
interface ApiErrorShape {
response?: {
data?: {
code?: string;
message?: string;
errors?: Array<{ field?: string; message?: string }>;
};
};
}
function translateMessage(message: string) {
const withoutField = message.replace(/^[a-zA-Z][\w.]*:\s*/, '').trim();
return MESSAGE_TRANSLATIONS[withoutField] ?? withoutField;
}
export function getApiErrorMessages(error: unknown, fallback: string): string[] {
const data = (error as ApiErrorShape)?.response?.data;
if (data?.code && CODE_MESSAGES[data.code]) {
return [CODE_MESSAGES[data.code]];
}
if (Array.isArray(data?.errors) && data.errors.length > 0) {
return data.errors
.map((item) => item.message?.trim())
.filter((message): message is string => Boolean(message))
.map(translateMessage);
}
if (data?.message) {
const messages = data.message
.split(/,\s*(?=[a-zA-Z][\w.]*:\s*)/)
.map(translateMessage)
.filter(Boolean);
if (messages.length > 0) return messages;
}
return [fallback];
}
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
const read = (path) => readFileSync(new URL(`../src/${path}`, import.meta.url), 'utf8');
test('routes provide Vietnamese document titles', () => {
const app = read('App.tsx');
const pageTitle = read('components/PageTitle.tsx');
assert.match(app, /PageTitle/);
assert.match(pageTitle, /Đăng nhập \| Data Crawler/);
assert.match(pageTitle, /Nhật ký hệ thống \| Data Crawler/);
});
test('login puts forgot-password below the password field and localizes invalid credentials', () => {
const login = read('pages/Login.tsx');
assert.ok(
login.indexOf('id="password"') < login.indexOf('to="/forgot-password"'),
'forgot-password link must follow the password field',
);
assert.match(login, /Tài khoản hoặc mật khẩu không chính xác/);
assert.doesNotMatch(login, /Chưa nhận được email xác thực/);
});
test('all requested password forms expose visibility controls', () => {
for (const file of [
'pages/Login.tsx',
'pages/Register.tsx',
'pages/Profile.tsx',
'pages/ResetPassword.tsx',
'pages/Users.tsx',
]) {
assert.match(read(file), /PasswordInput/, `${file} must use PasswordInput`);
}
});
test('register success explains verification email and offers resend', () => {
const register = read('pages/Register.tsx');
assert.match(register, /email xác thực đã được gửi/i);
assert.match(register, /\/auth\/resend-verification/);
assert.match(register, /Gửi lại email xác thực/);
});
test('forgot-password success offers resend of the reset email', () => {
const forgotPassword = read('pages/ForgotPassword.tsx');
assert.match(forgotPassword, /\/auth\/forgot-password/);
assert.match(forgotPassword, /Gửi lại email đặt lại mật khẩu/);
});
test('logs use an action dropdown and show email without truncating user ID', () => {
const logs = read('pages/Logs.tsx');
assert.match(logs, /<select/);
assert.match(logs, /log\.user\.email/);
assert.doesNotMatch(logs, /truncate max-w-\[120px\]/);
});
test('profile rejects no-op submissions instead of reporting success', () => {
const profile = read('pages/Profile.tsx');
assert.match(profile, /Bạn chưa thay đổi thông tin nào/);
assert.match(profile, /profileChanged/);
});
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