Skip to content

Commit af3ce94

Browse files
Merge pull request #137 from DevLoversTeam/feat/signup-email-verification
(SP: 3) [Backend][Frontend][UI] Implement secure authentication lifecycle with email verification and password recovery
2 parents 7d152a3 + 7b2920e commit af3ce94

34 files changed

Lines changed: 11325 additions & 8317 deletions
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
"use client";
2+
import { Link } from "@/i18n/routing";
3+
import { useState } from "react";
4+
import { useSearchParams } from "next/navigation";
5+
import { Button } from "@/components/ui/button";
6+
7+
export default function ForgotPasswordPage() {
8+
const searchParams = useSearchParams();
9+
const returnTo = searchParams.get("returnTo");
10+
11+
const [loading, setLoading] = useState(false);
12+
const [email, setEmail] = useState("");
13+
const [submitted, setSubmitted] = useState(false);
14+
const [error, setError] = useState<string | null>(null);
15+
16+
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
17+
e.preventDefault();
18+
setLoading(true);
19+
setError(null);
20+
21+
const res = await fetch("/api/auth/password-reset", {
22+
method: "POST",
23+
headers: { "Content-Type": "application/json" },
24+
body: JSON.stringify({ email }),
25+
});
26+
27+
setLoading(false);
28+
29+
if (!res.ok) {
30+
setError("Something went wrong. Please try again.");
31+
return;
32+
}
33+
34+
setSubmitted(true);
35+
}
36+
37+
return (
38+
<div className="mx-auto max-w-sm py-12">
39+
<h1 className="mb-6 text-2xl font-semibold">
40+
Forgot password
41+
</h1>
42+
43+
{submitted ? (
44+
<div className="rounded-md border border-green-400 bg-green-50 p-4 text-sm text-green-800">
45+
<p>
46+
If an account for{" "}
47+
<strong>{email}</strong> exists, we’ve sent a
48+
password reset link.
49+
</p>
50+
51+
<p className="mt-2">
52+
Please check your inbox and follow the
53+
instructions to reset your password.
54+
</p>
55+
56+
<Link
57+
href={
58+
returnTo
59+
? `/login?returnTo=${encodeURIComponent(returnTo)}`
60+
: "/login"
61+
}
62+
className="mt-4 inline-block underline"
63+
>
64+
Back to login
65+
</Link>
66+
</div>
67+
) : (
68+
<form onSubmit={onSubmit} className="space-y-4">
69+
<p className="text-sm text-gray-600">
70+
Enter your email address and we’ll send
71+
you a link to reset your password.
72+
</p>
73+
74+
<input
75+
type="email"
76+
required
77+
placeholder="Email"
78+
value={email}
79+
onChange={e => setEmail(e.target.value)}
80+
className="w-full rounded border px-3 py-2"
81+
/>
82+
83+
{error && (
84+
<p className="text-sm text-red-600">
85+
{error}
86+
</p>
87+
)}
88+
89+
<Button
90+
type="submit"
91+
disabled={loading}
92+
className="w-full"
93+
>
94+
{loading
95+
? "Sending reset link..."
96+
: "Send reset link"}
97+
</Button>
98+
</form>
99+
)}
100+
101+
{!submitted && (
102+
<p className="mt-4 text-sm text-gray-600">
103+
Remembered your password?{" "}
104+
<Link
105+
href={
106+
returnTo
107+
? `/login?returnTo=${encodeURIComponent(returnTo)}`
108+
: "/login"
109+
}
110+
className="underline"
111+
>
112+
Log in
113+
</Link>
114+
</p>
115+
)}
116+
</div>
117+
);
118+
}
Lines changed: 119 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,65 @@
11
"use client";
22

3-
import { useLocale } from 'next-intl';
4-
import { Link } from '@/i18n/routing';
3+
import { useLocale } from "next-intl";
4+
import { Link } from "@/i18n/routing";
55
import { useState } from "react";
66
import { useSearchParams } from "next/navigation";
77
import { getPendingQuizResult, clearPendingQuizResult } from "@/lib/quiz/guest-quiz";
88
import { Button } from "@/components/ui/button";
9-
import { OAuthButtons } from '@/components/auth/OAuthButtons';
9+
import { OAuthButtons } from "@/components/auth/OAuthButtons";
1010

1111
export default function LoginPage() {
1212
const searchParams = useSearchParams();
1313
const returnTo = searchParams.get("returnTo");
1414
const locale = useLocale();
15+
1516
const [loading, setLoading] = useState(false);
16-
const [error, setError] = useState<string | null>(null);
17+
const [errorMessage, setErrorMessage] = useState<string | null>(null);
18+
const [errorCode, setErrorCode] = useState<string | null>(null);
19+
const [email, setEmail] = useState("");
20+
const [verificationSent, setVerificationSent] = useState(false);
21+
const [showPassword, setShowPassword] = useState(false);
1722

1823
async function onSubmit(e: React.FormEvent<HTMLFormElement>) {
1924
e.preventDefault();
2025
setLoading(true);
21-
setError(null);
26+
setErrorMessage(null);
27+
setErrorCode(null);
28+
setVerificationSent(false);
2229

2330
const formData = new FormData(e.currentTarget);
31+
const emailValue = String(formData.get("email") || "");
32+
setEmail(emailValue);
2433

2534
const res = await fetch("/api/auth/login", {
2635
method: "POST",
2736
headers: { "Content-Type": "application/json" },
2837
body: JSON.stringify({
29-
email: formData.get("email"),
38+
email: emailValue,
3039
password: formData.get("password"),
3140
}),
3241
});
3342

43+
const data = await res.json().catch(() => null);
3444
setLoading(false);
3545

3646
if (!res.ok) {
37-
setError("Invalid email or password");
47+
setErrorCode(data?.code ?? null);
48+
49+
if (data?.code === "EMAIL_NOT_VERIFIED") {
50+
setErrorMessage(
51+
"Your email address is not verified. Please check your inbox."
52+
);
53+
} else {
54+
setErrorMessage("Invalid email or password");
55+
}
56+
3857
return;
3958
}
4059

41-
const data = await res.json();
4260
const pendingResult = getPendingQuizResult();
43-
if (pendingResult && data.userId) {
61+
62+
if (pendingResult && data?.userId) {
4463
try {
4564
const quizRes = await fetch("/api/quiz/guest-result", {
4665
method: "POST",
@@ -53,32 +72,52 @@ export default function LoginPage() {
5372
timeSpentSeconds: pendingResult.timeSpentSeconds,
5473
}),
5574
});
75+
5676
if (!quizRes.ok) {
5777
throw new Error(`Failed to save quiz result: ${quizRes.status}`);
5878
}
79+
5980
const result = await quizRes.json();
6081

6182
if (result.success) {
62-
sessionStorage.setItem('quiz_just_saved', JSON.stringify({
63-
score: result.score,
64-
total: result.totalQuestions,
65-
percentage: result.percentage,
66-
pointsAwarded: result.pointsAwarded,
67-
quizSlug: pendingResult.quizSlug,
68-
}));
83+
sessionStorage.setItem(
84+
"quiz_just_saved",
85+
JSON.stringify({
86+
score: result.score,
87+
total: result.totalQuestions,
88+
percentage: result.percentage,
89+
pointsAwarded: result.pointsAwarded,
90+
quizSlug: pendingResult.quizSlug,
91+
})
92+
);
6993
}
7094
} catch (err) {
71-
console.error('Failed to save quiz result:', err);
95+
console.error("Failed to save quiz result:", err);
7296
} finally {
7397
clearPendingQuizResult();
7498
}
7599

76100
window.location.href = `/${locale}/dashboard`;
77101
return;
78102
}
103+
79104
window.location.href = returnTo || `/${locale}/dashboard`;
80105
}
81106

107+
async function resendVerification() {
108+
if (!email) return;
109+
110+
await fetch("/api/auth/resend-verification", {
111+
method: "POST",
112+
headers: { "Content-Type": "application/json" },
113+
body: JSON.stringify({ email }),
114+
});
115+
116+
setVerificationSent(true);
117+
setErrorCode(null);
118+
setErrorMessage(null);
119+
}
120+
82121
return (
83122
<div className="mx-auto max-w-sm py-12">
84123
<h1 className="mb-6 text-2xl font-semibold">Log in</h1>
@@ -98,17 +137,62 @@ export default function LoginPage() {
98137
placeholder="Email"
99138
required
100139
className="w-full rounded border px-3 py-2"
140+
onChange={e => setEmail(e.target.value)}
101141
/>
102142

103-
<input
104-
name="password"
105-
type="password"
106-
placeholder="Password"
107-
required
108-
className="w-full rounded border px-3 py-2"
109-
/>
110-
111-
{error && <p className="text-sm text-red-600">{error}</p>}
143+
<div className="relative">
144+
<input
145+
name="password"
146+
type={showPassword ? "text" : "password"}
147+
placeholder="Password"
148+
required
149+
className="w-full rounded border px-3 py-2 pr-10"
150+
/>
151+
152+
<button
153+
type="button"
154+
aria-label={showPassword ? "Hide password" : "Show password"}
155+
onClick={() => setShowPassword(v => !v)}
156+
className="absolute inset-y-0 right-2 flex items-center text-sm text-gray-500"
157+
>
158+
{showPassword ? "Hide" : "Show"}
159+
</button>
160+
</div>
161+
162+
<div className="text-right">
163+
<Link
164+
href={
165+
returnTo
166+
? `/forgot-password?returnTo=${encodeURIComponent(returnTo)}`
167+
: "/forgot-password"
168+
}
169+
className="text-sm underline text-gray-600"
170+
>
171+
Forgot password?
172+
</Link>
173+
</div>
174+
175+
{errorMessage && !verificationSent && (
176+
<div className="rounded-md border border-yellow-400 bg-yellow-50 p-3 text-sm text-yellow-800">
177+
<p>{errorMessage}</p>
178+
179+
{errorCode === "EMAIL_NOT_VERIFIED" && (
180+
<button
181+
type="button"
182+
onClick={resendVerification}
183+
className="mt-2 underline"
184+
>
185+
Resend verification email
186+
</button>
187+
)}
188+
</div>
189+
)}
190+
191+
{verificationSent && (
192+
<div className="rounded-md border border-green-400 bg-green-50 p-3 text-sm text-green-800">
193+
Verification successfully sent to <strong>{email}</strong>
194+
</div>
195+
)}
112196

113197
<Button type="submit" disabled={loading} className="w-full">
114198
{loading ? "Logging in..." : "Log in"}
@@ -117,10 +201,17 @@ export default function LoginPage() {
117201

118202
<p className="mt-4 text-sm text-gray-600">
119203
Don’t have an account?{" "}
120-
<Link href={returnTo ? `/signup?returnTo=${encodeURIComponent(returnTo)}` : '/signup'} className="underline">
204+
<Link
205+
href={
206+
returnTo
207+
? `/signup?returnTo=${encodeURIComponent(returnTo)}`
208+
: "/signup"
209+
}
210+
className="underline"
211+
>
121212
Sign up
122213
</Link>
123214
</p>
124215
</div>
125216
);
126-
}
217+
}

0 commit comments

Comments
 (0)