Commit a9857517 authored by BangNSK's avatar BangNSK

fix: add resend actions to email flows

parent b8e34e6e
...@@ -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>
);
...@@ -11,6 +11,7 @@ export const Login: React.FC = () => { ...@@ -11,6 +11,7 @@ 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 [resending, setResending] = useState(false);
const [resendMessage, setResendMessage] = useState(''); const [resendMessage, setResendMessage] = useState('');
const [resendStatus, setResendStatus] = useState<'success' | 'error' | null>(null); const [resendStatus, setResendStatus] = useState<'success' | 'error' | null>(null);
...@@ -20,6 +21,7 @@ export const Login: React.FC = () => { ...@@ -20,6 +21,7 @@ export const Login: React.FC = () => {
setError(''); setError('');
setResendMessage(''); setResendMessage('');
setResendStatus(null); setResendStatus(null);
setCanResendVerification(false);
setSubmitting(true); setSubmitting(true);
try { try {
await login(email, password); await login(email, password);
...@@ -28,6 +30,7 @@ export const Login: React.FC = () => { ...@@ -28,6 +30,7 @@ export const Login: React.FC = () => {
console.error(err); console.error(err);
const code = err.response?.data?.code; const code = err.response?.data?.code;
const isInactiveAccount = code === 'USER_INACTIVE' || err.response?.status === 403; const isInactiveAccount = code === 'USER_INACTIVE' || err.response?.status === 403;
setCanResendVerification(isInactiveAccount);
setError( setError(
isInactiveAccount 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.' ? '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.'
...@@ -82,6 +85,17 @@ export const Login: React.FC = () => { ...@@ -82,6 +85,17 @@ export const Login: React.FC = () => {
{error && ( {error && (
<div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-700 border border-rose-100" role="alert"> <div className="rounded-lg bg-rose-50 p-4 text-sm text-rose-700 border border-rose-100" role="alert">
<p>{error}</p> <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> </div>
)} )}
{resendMessage && ( {resendMessage && (
...@@ -165,19 +179,6 @@ export const Login: React.FC = () => { ...@@ -165,19 +179,6 @@ export const Login: React.FC = () => {
</div> </div>
</form> </form>
<div className="text-center text-sm text-slate-500">
<span>Chưa nhận được email xác thực? </span>
<button
type="button"
onClick={() => void handleResendVerification()}
disabled={resending}
className="inline-flex min-h-11 items-center font-semibold text-indigo-600 hover:text-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>
</div>
<div className="text-center text-sm text-slate-500"> <div className="text-center text-sm text-slate-500">
Chưa có tài khoản?{' '} Chưa có tài khoản?{' '}
<Link to="/register" className="font-medium text-indigo-600 hover:text-indigo-500 transition-colors"> <Link to="/register" className="font-medium text-indigo-600 hover:text-indigo-500 transition-colors">
......
...@@ -4,6 +4,7 @@ import { useAuth } from '../context/auth'; ...@@ -4,6 +4,7 @@ import { useAuth } from '../context/auth';
import { Database, Mail, Loader2, User } from 'lucide-react'; import { Database, Mail, Loader2, User } from 'lucide-react';
import { PasswordInput } from '../components/PasswordInput'; import { PasswordInput } from '../components/PasswordInput';
import { getApiErrorMessages } from '../utils/apiError'; 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();
...@@ -15,6 +16,9 @@ export const Register: React.FC = () => { ...@@ -15,6 +16,9 @@ export const Register: React.FC = () => {
const [errors, setErrors] = useState<string[]>([]); 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();
...@@ -29,9 +33,6 @@ export const Register: React.FC = () => { ...@@ -29,9 +33,6 @@ export const Register: React.FC = () => {
try { try {
await register(email, password, fullName); await register(email, password, fullName);
setSuccess(true); setSuccess(true);
setTimeout(() => {
navigate('/login');
}, 4000);
} catch (err: any) { } catch (err: any) {
console.error(err); console.error(err);
setErrors(getApiErrorMessages(err, 'Đă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.'));
...@@ -40,6 +41,24 @@ export const Register: React.FC = () => { ...@@ -40,6 +41,24 @@ export const Register: React.FC = () => {
} }
}; };
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">
...@@ -65,14 +84,45 @@ export const Register: React.FC = () => { ...@@ -65,14 +84,45 @@ export const Register: React.FC = () => {
)} )}
{success && ( {success && (
<div className="rounded-lg bg-emerald-50 p-4 text-sm text-emerald-700 border border-emerald-100" aria-live="polite"> <div className="space-y-4 rounded-lg border border-emerald-200 bg-emerald-50 p-4 text-sm text-emerald-700" aria-live="polite">
<div>
<p className="font-semibold">Đăng ký thành công.</p> <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">
<p className="mt-1 text-emerald-600">Đang chuyển hướng về trang đăng nhập...</p> 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>
...@@ -141,7 +191,7 @@ export const Register: React.FC = () => { ...@@ -141,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?{' '}
......
...@@ -19,11 +19,7 @@ test('login puts forgot-password below the password field and localizes invalid ...@@ -19,11 +19,7 @@ test('login puts forgot-password below the password field and localizes invalid
'forgot-password link must follow the password field', '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, /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/); assert.doesNotMatch(login, /Chưa nhận được email xác thực/);
assert.ok(
login.indexOf('</form>') < login.indexOf('Gửi lại email xác thực'),
'resend verification must always be visible below the login form',
);
}); });
test('all requested password forms expose visibility controls', () => { test('all requested password forms expose visibility controls', () => {
...@@ -37,9 +33,17 @@ test('all requested password forms expose visibility controls', () => { ...@@ -37,9 +33,17 @@ test('all requested password forms expose visibility controls', () => {
} }
}); });
test('register success explains verification email before redirecting', () => { test('register success explains verification email and offers resend', () => {
const register = read('pages/Register.tsx'); const register = read('pages/Register.tsx');
assert.match(register, /email xác thực đã được gửi/i); 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', () => { test('logs use an action dropdown and show email without truncating user ID', () => {
......
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