Commit 673afd42 authored by BangNSK's avatar BangNSK

fix: address authentication and admin UI feedback

parent 24bab604
<!doctype html>
<html lang="en">
<html lang="vi">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
<title>Data Crawler</title>
</head>
<body>
<div id="root"></div>
......
......@@ -13,11 +13,13 @@ import { Profile } from './pages/Profile';
import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword';
import { VerifyEmail } from './pages/VerifyEmail';
import { PageTitle } from './components/PageTitle';
export default function App() {
return (
<AuthProvider>
<BrowserRouter>
<PageTitle />
<Routes>
{/* Guest Routes */}
<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',
'/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>
);
}
......@@ -2,6 +2,7 @@ import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useAuth } from '../context/auth';
import { Database, Lock, Mail, Loader2 } from 'lucide-react';
import { api } from '../services/api';
export const Login: React.FC = () => {
const { login } = useAuth();
......@@ -10,22 +11,48 @@ export const Login: React.FC = () => {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const [canResendVerification, setCanResendVerification] = useState(false);
const [resending, setResending] = useState(false);
const [resendMessage, setResendMessage] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setResendMessage('');
setCanResendVerification(false);
setSubmitting(true);
try {
await login(email, password);
navigate('/');
} catch (err: any) {
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;
setCanResendVerification(code === 'USER_INACTIVE');
setError(
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 {
setSubmitting(false);
}
};
const handleResendVerification = async () => {
setResending(true);
setResendMessage('');
try {
const response = await api.post('/auth/resend-verification', { email });
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) {
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 (
<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">
......@@ -42,8 +69,24 @@ export const Login: React.FC = () => {
</div>
{error && (
<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-600 border border-rose-100">
{error}
<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-700 border border-rose-100" role="alert">
<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: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 border-emerald-100 bg-emerald-50 p-4 text-sm text-emerald-700" aria-live="polite">
{resendMessage}
</div>
)}
......@@ -69,11 +112,6 @@ export const Login: React.FC = () => {
placeholder="admin@crawl.local"
/>
</div>
<div className="mt-2 text-right">
<Link to="/forgot-password" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
Quên mật khẩu?
</Link>
</div>
</div>
<div>
......@@ -96,6 +134,11 @@ export const Login: React.FC = () => {
placeholder="••••••••"
/>
</div>
<div className="mt-2 text-right">
<Link to="/forgot-password" className="text-sm font-medium text-indigo-600 hover:text-indigo-500">
Quên mật khẩu?
</Link>
</div>
</div>
</div>
......
......@@ -4,7 +4,13 @@ import { ScrollText, RefreshCw, Search, ChevronDown } from 'lucide-react';
interface AuditLog {
id: string;
userId: string;
userId: string | null;
user: {
id: string;
email: string;
fullName: string | null;
role: string;
} | null;
action: string;
ipAddress: string | null;
userAgent: string | null;
......@@ -27,11 +33,39 @@ const ACTION_COLORS: Record<string, string> = {
LOGIN: 'bg-slate-100 text-slate-600',
LOGOUT: 'bg-slate-100 text-slate-500',
REGISTER: 'bg-violet-100 text-violet-700',
CREATE_USER: 'bg-emerald-100 text-emerald-700',
DELETE_USER: 'bg-rose-100 text-rose-700',
UPDATE_USER: 'bg-indigo-100 text-indigo-700',
ADMIN_CREATE_USER: 'bg-emerald-100 text-emerald-700',
ADMIN_DELETE_USER: 'bg-rose-100 text-rose-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',
};
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',
'REVOKE_API_KEY',
'CREATE_WEBHOOK_CONFIG',
'DELETE_WEBHOOK_CONFIG',
];
export const Logs: React.FC = () => {
const [logs, setLogs] = useState<AuditLog[]>([]);
const [meta, setMeta] = useState<Meta>({ total: 0, page: 1, limit: 15, totalPages: 1 });
......@@ -139,14 +173,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"
/>
</div>
<div className="relative">
<input
type="text"
placeholder="Lọc theo Action (CREATE_JOB...)"
<div>
<label htmlFor="action-filter" className="mb-1 block text-xs font-medium text-slate-600">
Hành động
</label>
<select
id="action-filter"
value={actionInput}
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>
<button
type="submit"
......@@ -183,7 +224,7 @@ export const Logs: React.FC = () => {
<tr>
<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">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">Chi tiết</th>
</tr>
......@@ -197,9 +238,21 @@ export const Logs: React.FC = () => {
</td>
<td className="px-6 py-3">{actionBadge(log.action)}</td>
<td className="px-6 py-3">
<span className="font-mono text-xs text-slate-500 truncate max-w-[120px] block" title={log.userId}>
{log.userId}
</span>
{log.user ? (
<div className="min-w-64">
<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 className="px-6 py-3 text-xs font-mono text-slate-500">
{log.ipAddress || '—'}
......
import { useAuth } from '../context/auth';
import { useEffect, useState } from '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 = () => {
const { user, updateProfile, changePassword } = useAuth();
......@@ -17,21 +19,49 @@ export const Profile: React.FC = () => {
const handleSubmit = async (event: React.FormEvent) => {
event.preventDefault();
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);
try {
if (password) {
if (passwordChanged) {
await changePassword({
currentPassword: oldPassword,
newPassword: password,
confirmPassword,
});
}
await updateProfile({ fullName: fullName.trim() });
if (profileChanged) {
await updateProfile({ fullName: normalizedFullName });
}
setOldPassword(''); setPassword(''); setConfirmPassword('');
setMessage('Cập nhật hồ sơ thành công.');
} catch (err: any) {
setError(err.response?.data?.message || 'Không thể cập nhật hồ sơ.');
setMessage(
profileChanged && passwordChanged
? '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); }
};
......@@ -91,8 +121,8 @@ export const Profile: React.FC = () => {
<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>
</div>
{error && <div className="rounded-lg border border-rose-200 bg-rose-50 p-3 text-sm text-rose-700">{error}</div>}
{message && <div className="rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-700">{message}</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" aria-live="polite">{message}</div>}
<div>
<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" />
......@@ -100,9 +130,31 @@ export const Profile: React.FC = () => {
<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="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" />
<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" />
<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" />
<PasswordInput
id="current-password"
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>
<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>
......
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
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';
export const Register: React.FC = () => {
const { register } = useAuth();
......@@ -10,16 +12,16 @@ export const Register: React.FC = () => {
const [fullName, setFullName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
const [errors, setErrors] = useState<string[]>([]);
const [success, setSuccess] = useState(false);
const [submitting, setSubmitting] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError('');
setErrors([]);
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;
}
......@@ -29,10 +31,10 @@ export const Register: React.FC = () => {
setSuccess(true);
setTimeout(() => {
navigate('/login');
}, 2000);
}, 4000);
} catch (err: any) {
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 {
setSubmitting(false);
}
......@@ -53,15 +55,20 @@ export const Register: React.FC = () => {
</p>
</div>
{error && (
<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-600 border border-rose-100">
{error}
{errors.length > 0 && (
<div className="rounded-lg border border-rose-100 bg-rose-50 p-4 text-sm text-rose-700" role="alert">
<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>
)}
{success && (
<div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-600 border border-emerald-100">
Đăng ký thành công! Đang chuyển hướng về trang đăng nhập...
<div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-700 border border-emerald-100" aria-live="polite">
<p className="font-semibold">Đăng ký thành công.</p>
<p className="mt-1">Email xác thực đã được gửi. Vui lòng kiểm tra hộp thư trước khi đăng nhập.</p>
<p className="mt-1 text-emerald-600">Đang chuyển hướng về trang đăng nhập...</p>
</div>
)}
......@@ -97,47 +104,28 @@ export const Register: React.FC = () => {
</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"
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>
<PasswordInput
id="password"
name="password"
label="Mật khẩu"
autoComplete="new-password"
required
minLength={8}
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
/>
<div>
<label htmlFor="confirm-password" className="block text-sm font-medium text-slate-700 mb-1">
Xác nhận 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="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>
<PasswordInput
id="confirm-password"
name="confirmPassword"
label="Xác nhận mật khẩu"
autoComplete="new-password"
required
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="••••••••"
/>
</div>
<div>
......
......@@ -3,11 +3,83 @@ import { Link, useSearchParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { api } from '../services/api';
import { AuthCard } from './ForgotPassword';
import { PasswordInput } from '../components/PasswordInput';
import { getApiErrorMessages } from '../utils/apiError';
export const ResetPassword = () => {
const [params] = useSearchParams(); const token = params.get('token') || '';
const [password,setPassword]=useState(''); const [confirm,setConfirm]=useState('');
const [loading,setLoading]=useState(false); const [error,setError]=useState(''); const [success,setSuccess]=useState(false);
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);}};
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>;
};
\ No newline at end of file
const [params] = useSearchParams();
const token = params.get('token') || '';
const [password, setPassword] = useState('');
const [confirm, setConfirm] = useState('');
const [loading, setLoading] = useState(false);
const [errors, setErrors] = useState<string[]>([]);
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 {
ShieldCheck,
User,
} from 'lucide-react';
import { PasswordInput } from '../components/PasswordInput';
import { getApiErrorMessages } from '../utils/apiError';
interface UserItem {
id: string;
......@@ -119,8 +121,8 @@ export const Users: React.FC = () => {
setShowCreate(false);
setCreateForm(emptyCreate);
fetchUsers(1, search, true);
} catch (err: any) {
setCreateError(err.response?.data?.message || 'Tạo user thất bại.');
} catch (err: unknown) {
setCreateError(getApiErrorMessages(err, 'Tạo người dùng thất bại.').join('\n'));
} finally {
setCreating(false);
}
......@@ -145,8 +147,8 @@ export const Users: React.FC = () => {
});
setEditUser(null);
fetchUsers(meta.page, search, true);
} catch (err: any) {
setEditError(err.response?.data?.message || 'Cập nhật thất bại.');
} catch (err: unknown) {
setEditError(getApiErrorMessages(err, 'Cập nhật thất bại.').join('\n'));
} finally {
setEditing(false);
}
......@@ -360,17 +362,17 @@ export const Users: React.FC = () => {
placeholder="user@example.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Mật khẩu *</label>
<input
type="password"
required
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
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"
placeholder="Tối thiểu 8 ký tự"
/>
</div>
<PasswordInput
id="create-user-password"
label="Mật khẩu *"
required
minLength={8}
autoComplete="new-password"
value={createForm.password}
onChange={(e) => setCreateForm({ ...createForm, password: e.target.value })}
placeholder="Tối thiểu 8 ký tự"
inputClassName="focus:border-violet-500 focus:ring-violet-500"
/>
<div>
<label className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label>
<input
......@@ -443,12 +445,18 @@ export const Users: React.FC = () => {
<select
value={editForm.role}
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="VIEWER">VIEWER</option>
<option value="ADMIN">ADMIN</option>
</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 className="flex items-center gap-2">
<input
......
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.',
};
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.match(login, /Gửi lại email xác thực/);
});
test('all requested password forms expose visibility controls', () => {
for (const file of [
'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 before redirecting', () => {
const register = read('pages/Register.tsx');
assert.match(register, /email xác thực đã được gửi/i);
});
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