Commit dea39a22 authored by BangNSK's avatar BangNSK

feat(auth): add forgot-password, reset-password, verify-email pages and update auth flow

parent c381d01f
VITE_API_BASE_URL=http://localhost:3000/api/v1
......@@ -10,6 +10,9 @@ import { JobDetail } from './pages/JobDetail';
import { Users } from './pages/Users';
import { Logs } from './pages/Logs';
import { Profile } from './pages/Profile';
import { ForgotPassword } from './pages/ForgotPassword';
import { ResetPassword } from './pages/ResetPassword';
import { VerifyEmail } from './pages/VerifyEmail';
export default function App() {
return (
......@@ -33,6 +36,9 @@ export default function App() {
</GuestRoute>
}
/>
<Route path="/forgot-password" element={<GuestRoute><ForgotPassword /></GuestRoute>} />
<Route path="/reset-password" element={<GuestRoute><ResetPassword /></GuestRoute>} />
<Route path="/verify-email" element={<VerifyEmail />} />
{/* Protected Dashboard Layout Routes */}
<Route
......
......@@ -51,15 +51,20 @@ export const AuthProvider: React.FC<{ children: React.ReactNode }> = ({ children
}
};
const register = async (email: string, password: string) => {
await api.post('/auth/register', { email, password });
const register = async (email: string, password: string, fullName?: string) => {
await api.post('/auth/register', { email, password, fullName: fullName?.trim() || undefined });
};
const updateProfile = async (data: { fullName?: string; oldPassword?: string; password?: string }) => {
const response = await api.put('/auth/me', data);
if (response.data.success) setUser(response.data.data);
};
const isAdmin = user?.role === 'ADMIN';
const isCrawler = user?.role === 'ADMIN' || user?.role === 'CRAWLER_USER';
return (
<AuthContext.Provider value={{ user, loading, login, logout, register, isAdmin, isCrawler }}>
<AuthContext.Provider value={{ user, loading, login, logout, register, updateProfile, isAdmin, isCrawler }}>
{children}
</AuthContext.Provider>
);
......
......@@ -16,7 +16,8 @@ export interface AuthContextType {
loading: boolean;
login: (email: string, password: string) => Promise<void>;
logout: () => Promise<void>;
register: (email: string, password: string) => Promise<void>;
register: (email: string, password: string, fullName?: string) => Promise<void>;
updateProfile: (data: { fullName?: string; oldPassword?: string; password?: string }) => Promise<void>;
isAdmin: boolean;
isCrawler: boolean;
}
......
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { Database, Loader2, Mail } from 'lucide-react';
import { api } from '../services/api';
export const ForgotPassword = () => {
const [email, setEmail] = useState('');
const [loading, setLoading] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
const submit = async (event: React.FormEvent) => {
event.preventDefault(); setLoading(true); setError('');
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.'); }
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">
{error && <div className="rounded-lg bg-rose-50 p-3 text-sm text-rose-700">{error}</div>}
<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>
<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>
</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>;
\ No newline at end of file
......@@ -14,13 +14,21 @@ import {
Download,
Loader2,
Image as ImageIcon,
Link as LinkIcon,
} from 'lucide-react';
import { getJobProgress } from '../utils/jobProgress';
const ACTIVE_STATUSES = ['PENDING', 'QUEUED', 'RUNNING', 'PROCESSING_EXPORT'];
const POLL_INTERVAL_MS = 3000;
const WARNING_CONFIG: Record<string, { label: string; color: string }> = {
MISSING_TITLE: { label: 'Thiếu tiêu đề', color: 'text-amber-700 bg-amber-50' },
MISSING_DESCRIPTION: { label: 'Thiếu mô tả', color: 'text-amber-700 bg-amber-50' },
TOO_SHORT: { label: 'Quá ngắn', color: 'text-amber-700 bg-amber-50' },
DUPLICATE_CONTENT: { label: 'Trùng nội dung', color: 'text-rose-700 bg-rose-50' },
NAV_NOISE: { label: 'Nav noise', color: 'text-slate-600 bg-slate-100' },
LOW_QUALITY_SCORE: { label: 'Chất lượng thấp', color: 'text-rose-700 bg-rose-50' },
};
interface CrawlJob {
id: string;
startUrl: string;
......@@ -49,6 +57,10 @@ interface CrawlPage {
statusCode: number | null;
errorMessage: string | null;
crawledAt: string | null;
hasSensitiveData: boolean | null;
warnings: string[];
dataQualityScore: number | null;
wordCount: number | null;
}
interface CrawlExport {
......@@ -280,6 +292,8 @@ export const JobDetail: React.FC = () => {
return { label: 'Đã hủy', color: 'text-slate-500 bg-slate-100 border-slate-200', icon: <XCircle className="h-4 w-4" /> };
case 'PROCESSING_EXPORT':
return { label: 'Đang xuất file', color: 'text-purple-700 bg-purple-50 border-purple-100', icon: <Loader2 className="h-4 w-4 animate-spin" /> };
case 'EXPIRED':
return { label: 'Đã hết hạn', color: 'text-slate-500 bg-slate-100 border-slate-200', icon: <Clock className="h-4 w-4" /> };
default:
return { label: status, color: 'text-slate-600 bg-slate-100 border-slate-200', icon: null };
}
......@@ -565,6 +579,8 @@ export const JobDetail: React.FC = () => {
<th className="px-6 py-3">Tiêu đề</th>
<th className="px-6 py-3">Trạng thái</th>
<th className="px-6 py-3">HTTP Code</th>
<th className="px-6 py-3">Chất lượng</th>
<th className="px-6 py-3">Cảnh báo</th>
<th className="px-6 py-3">Thời gian cào</th>
</tr>
</thead>
......@@ -595,6 +611,50 @@ export const JobDetail: React.FC = () => {
</span>
) : '–'}
</td>
<td className="px-6 py-3">
<div className="flex flex-col gap-1 items-center">
{page.dataQualityScore !== null && page.dataQualityScore !== undefined ? (
<span
title={`Điểm chất lượng: ${page.dataQualityScore}/100`}
className={`inline-block text-xs font-bold px-2 py-0.5 rounded-full ${
page.dataQualityScore >= 70
? 'bg-emerald-50 text-emerald-700'
: page.dataQualityScore >= 40
? 'bg-amber-50 text-amber-700'
: 'bg-rose-50 text-rose-700'
}`}
>
{page.dataQualityScore}/100
</span>
) : (
<span className="text-slate-300 text-xs"></span>
)}
{page.hasSensitiveData && (
<span
title="Trang này chứa dữ liệu nhạy cảm (email/SĐT/CCCD...)"
className="inline-flex items-center gap-0.5 text-xs font-semibold text-rose-600 bg-rose-50 border border-rose-200 px-1.5 py-0.5 rounded"
>
🔒 Nhạy cảm
</span>
)}
</div>
</td>
<td className="px-6 py-3">
{page.warnings && page.warnings.length > 0 ? (
<div className="flex flex-col gap-1">
{page.warnings.map((w) => {
const cfg = WARNING_CONFIG[w] ?? { label: w, color: 'text-slate-600 bg-slate-100' };
return (
<span key={w} className={`text-xs px-1.5 py-0.5 rounded font-medium ${cfg.color}`}>
{cfg.label}
</span>
);
})}
</div>
) : (
<span className="text-slate-300 text-xs"></span>
)}
</td>
<td className="px-6 py-3 text-slate-400 text-xs">
{page.crawledAt ? new Date(page.crawledAt).toLocaleString('vi-VN') : '–'}
</td>
......
......@@ -69,6 +69,11 @@ 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>
......
import { useAuth } from '../context/auth';
import { Shield, Mail, UserCheck } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Shield, Mail, UserCheck, Loader2, Save, KeyRound } from 'lucide-react';
export const Profile: React.FC = () => {
const { user } = useAuth();
const { user, updateProfile } = useAuth();
const [fullName, setFullName] = useState(user?.fullName || '');
const [oldPassword, setOldPassword] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState('');
const [error, setError] = useState('');
useEffect(() => setFullName(user?.fullName || ''), [user?.fullName]);
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.');
setSubmitting(true);
try {
await updateProfile({ fullName: fullName.trim(), ...(password ? { oldPassword, password } : {}) });
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ơ.');
} finally { setSubmitting(false); }
};
return (
<div className="max-w-2xl mx-auto space-y-6">
......@@ -54,6 +78,31 @@ export const Profile: React.FC = () => {
</div>
</div>
</div>
<form onSubmit={handleSubmit} className="rounded-xl border border-slate-200 bg-white p-6 shadow-sm space-y-5">
<div>
<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>}
<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" />
</div>
<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" />
</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>
<button disabled={submitting} className="inline-flex items-center rounded-lg bg-indigo-600 px-4 py-2 text-sm font-semibold text-white hover:bg-indigo-700 disabled:opacity-60">
{submitting ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <Save className="mr-2 h-4 w-4" />} Lưu thay đổi
</button>
</form>
</div>
);
};
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 { Database, Lock, Mail, Loader2, User } from 'lucide-react';
export const Register: React.FC = () => {
const { register } = useAuth();
const navigate = useNavigate();
const [email, setEmail] = useState('');
const [fullName, setFullName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
......@@ -24,7 +25,7 @@ export const Register: React.FC = () => {
setSubmitting(true);
try {
await register(email, password);
await register(email, password, fullName);
setSuccess(true);
setTimeout(() => {
navigate('/login');
......@@ -66,6 +67,15 @@ export const Register: React.FC = () => {
<form className="mt-8 space-y-6" onSubmit={handleSubmit}>
<div className="space-y-4 rounded-md shadow-sm">
<div>
<label htmlFor="full-name" className="block text-sm font-medium text-slate-700 mb-1">Họ và tên</label>
<div className="relative">
<User className="pointer-events-none absolute left-3 top-2.5 h-5 w-5 text-slate-400" />
<input id="full-name" autoComplete="name" value={fullName} onChange={(e) => setFullName(e.target.value)}
className="block w-full rounded-lg border border-slate-300 py-2.5 pl-10 pr-3 text-sm focus:border-indigo-500 focus:outline-none focus:ring-1 focus:ring-indigo-500"
placeholder="Nguyễn Văn A" />
</div>
</div>
<div>
<label htmlFor="email-address" className="block text-sm font-medium text-slate-700 mb-1">
Địa chỉ Email
......
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { api } from '../services/api';
import { AuthCard } from './ForgotPassword';
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
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { Loader2 } from 'lucide-react';
import { api } from '../services/api';
import { AuthCard } from './ForgotPassword';
export const VerifyEmail = () => {
const [params]=useSearchParams(); const token=params.get('token')||''; const [state,setState]=useState<'loading'|'success'|'error'>(token?'loading':'error'); const [message,setMessage]=useState(token?'':'Thiếu mã xác minh email.');
useEffect(()=>{if(!token)return;api.post('/auth/verify-email',{token}).then(res=>{setMessage(res.data.message||'Email đã được xác minh.');setState('success');}).catch(err=>{setMessage(err.response?.data?.message||'Liên kết xác minh không hợp lệ hoặc đã hết hạn.');setState('error');});},[token]);
return <AuthCard title="Xác minh email" subtitle="Đang kiểm tra liên kết xác minh của bạn."><div className="text-center space-y-4">{state==='loading'?<Loader2 className="mx-auto h-8 w-8 animate-spin text-indigo-600"/>:<div className={`rounded-lg p-4 text-sm ${state==='success'?'bg-emerald-50 text-emerald-700':'bg-rose-50 text-rose-700'}`}>{message}</div>} {state!=='loading'&&<Link to="/login" className="inline-block font-semibold text-indigo-600">Đi tới đăng nhập</Link>}</div></AuthCard>;
};
\ No newline at end of file
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