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',
......
This diff is collapsed.
This diff is collapsed.
...@@ -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>
);
This diff is collapsed.
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