feat(auth) : add optional email verification and profile setup
CI / Verify (push) Failing after 57s
CI / Build immutable images and deploy (push) Skipped

This commit is contained in:
2026-10-08 18:46:04 +07:00 Unverified
parent 01f44688fb
commit da5de3fa8c
8 changed files with 111 additions and 16 deletions
+2 -2
View File
@@ -5,6 +5,6 @@ export const instant = false;
export default async function RegisterPage({ searchParams }: PageProps<"/auth/register">) { export default async function RegisterPage({ searchParams }: PageProps<"/auth/register">) {
await connection(); await connection();
const { next } = await searchParams; const { next, step } = await searchParams;
return <AccountPage mode="register" next={next} />; return <AccountPage mode="register" next={next} setup={step === "profile"} />;
} }
+7 -3
View File
@@ -168,7 +168,7 @@ export function AccountForm({
email: pendingEmail, otp: String(form.get("otp") ?? "").trim(), email: pendingEmail, otp: String(form.get("otp") ?? "").trim(),
}); });
if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง"); if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง");
router.replace(`/profile?setup=1&next=${encodeURIComponent(nextPath)}`); router.replace(`/auth/register?step=profile&next=${encodeURIComponent(nextPath)}`);
router.refresh(); router.refresh();
return; return;
} }
@@ -199,7 +199,7 @@ export function AccountForm({
<CardHeader> <CardHeader>
<CardTitle>{register ? pendingEmail ? "ยืนยันอีเมล" : "สร้างบัญชี Buzz Guide" : "เข้าสู่ระบบ"}</CardTitle> <CardTitle>{register ? pendingEmail ? "ยืนยันอีเมล" : "สร้างบัญชี Buzz Guide" : "เข้าสู่ระบบ"}</CardTitle>
{nextPath.startsWith("/commission") && <CardDescription>เข้าสู่ระบบเพื่อบันทึกคำขอ รับผล และพูดคุยกับทีมงาน</CardDescription>} {nextPath.startsWith("/commission") && <CardDescription>เข้าสู่ระบบเพื่อบันทึกคำขอ รับผล และพูดคุยกับทีมงาน</CardDescription>}
{register && <CardDescription>ขั้นตอน {pendingEmail ? "2 จาก 2 - ยืนยันอีเมล" : "1 จาก 2 - ข้อมูลบัญชี"}</CardDescription>} {register && <CardDescription>ขั้นตอน {pendingEmail ? "2 จาก 3 - ยืนยันอีเมล" : "1 จาก 3 - ข้อมูลบัญชี"}</CardDescription>}
</CardHeader> </CardHeader>
<CardContent className="flex flex-col gap-4"> <CardContent className="flex flex-col gap-4">
{verified && <Alert><AlertDescription>ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ</AlertDescription></Alert>} {verified && <Alert><AlertDescription>ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ</AlertDescription></Alert>}
@@ -268,8 +268,12 @@ export function AccountForm({
</>} </>}
{error && <Alert variant="destructive" role="alert"><AlertDescription>{error}</AlertDescription></Alert>} {error && <Alert variant="destructive" role="alert"><AlertDescription>{error}</AlertDescription></Alert>}
<Button type="submit" disabled={busy || (captcha && !token && !pendingEmail)}> <Button type="submit" disabled={busy || (captcha && !token && !pendingEmail)}>
{busy ? "กำลังดำเนินการ…" : register ? pendingEmail ? "ยืนยันและสร้างบัญชี" : "ถัดไป - ส่งรหัสยืนยัน" : "เข้าสู่ระบบ"} {busy ? "กำลังดำเนินการ…" : register ? pendingEmail ? "ยืนยันอีเมล" : "ถัดไป - ส่งรหัสยืนยัน" : "เข้าสู่ระบบ"}
</Button> </Button>
{register && pendingEmail && <Button type="button" variant="outline" disabled={busy} onClick={() => {
router.replace(`/auth/register?step=profile&next=${encodeURIComponent(nextPath)}`);
router.refresh();
}}>ข้ามการยืนยันอีเมลตอนนี้</Button>}
</FieldGroup> </FieldGroup>
</form> </form>
{register ? <Button variant="link" nativeButton={false} {register ? <Button variant="link" nativeButton={false}
+62
View File
@@ -0,0 +1,62 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
const mocks = vi.hoisted(() => ({ session: vi.fn(), userRows: vi.fn(), select: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: (path: string) => { throw new Error(`redirect:${path}`); } }));
vi.mock("@/lib/auth/server", () => ({ getCustomerSession: mocks.session }));
vi.mock("@/db", () => ({ getDb: () => ({ select: mocks.select }) }));
vi.mock("@/components/public/site-header", () => ({ SiteHeader: () => null }));
vi.mock("./account-form", () => ({ AccountForm: () => <div>account details</div> }));
vi.mock("./profile-form", () => ({ ProfileForm: ({ name, nextPath, setup }: {
name: string; nextPath: string; setup: boolean;
}) => <div data-next={nextPath} data-setup={setup}>{name}</div> }));
const { AccountPage } = await import("./account-page");
beforeEach(() => {
vi.clearAllMocks();
vi.stubEnv("NODE_ENV", "development");
mocks.session.mockResolvedValue({ user: { id: "user-1", email: "[email protected]",
emailVerified: false, role: "user" }, session: { id: "session-1" } });
mocks.select.mockReturnValue({ from: () => ({ where: () => ({ limit: mocks.userRows }) }) });
mocks.userRows.mockResolvedValue([{ name: "Registered Name", email: "[email protected]", image: null }]);
});
afterEach(() => { vi.unstubAllEnvs(); });
describe("registration profile step", () => {
it("resumes setup for an unverified session using current profile data and the requested destination", async () => {
const html = renderToStaticMarkup(await AccountPage({ mode: "register", setup: true, next: "/commission?from=signup" }));
expect(html).toContain("Registered Name");
expect(html).toContain('data-setup="true"');
expect(html).toContain('data-next="/commission?from=signup"');
expect(mocks.select).toHaveBeenCalledOnce();
});
it("returns signed-out visitors to account registration", async () => {
mocks.session.mockResolvedValue(null);
const html = renderToStaticMarkup(await AccountPage({ mode: "register", setup: true, next: "/commission" }));
expect(html).toContain("account details");
expect(mocks.select).not.toHaveBeenCalled();
});
it("sanitizes external and unauthorized admin destinations before completing setup", async () => {
for (const next of ["https://elsewhere.test", "/admin/guides"]) {
const html = renderToStaticMarkup(await AccountPage({ mode: "register", setup: true, next }));
expect(html).toContain('data-next="/"');
}
});
it("preserves ordinary signed-in login and registration redirects", async () => {
for (const mode of ["login", "register"] as const) {
await expect(AccountPage({ mode, next: "/commission" })).rejects.toThrow("redirect:/commission");
}
expect(mocks.select).not.toHaveBeenCalled();
});
it("does not expose profile setup through the login page", async () => {
await expect(AccountPage({ mode: "login", setup: true, next: "/" })).rejects.toThrow("redirect:/");
expect(mocks.select).not.toHaveBeenCalled();
});
});
+16 -3
View File
@@ -1,17 +1,30 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { eq } from "drizzle-orm";
import { getDb } from "@/db";
import { users } from "@/db/schema";
import { SiteHeader } from "@/components/public/site-header"; import { SiteHeader } from "@/components/public/site-header";
import { isAuthorizedAdmin } from "@/lib/auth/authorization"; import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { getCustomerSession } from "@/lib/auth/server"; import { getCustomerSession } from "@/lib/auth/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path"; import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { AccountForm } from "./account-form"; import { AccountForm } from "./account-form";
import { ProfileForm } from "./profile-form";
export async function AccountPage({ mode, next, verified, verificationError }: { export async function AccountPage({ mode, next, verified, verificationError, setup = false }: {
mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean; mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean; setup?: boolean;
}) { }) {
const nextPath = safeAuthReturnPath(next); const nextPath = safeAuthReturnPath(next);
const session = await getCustomerSession(); const session = await getCustomerSession();
if (session) { if (session) {
if (nextPath.startsWith("/admin") && !isAuthorizedAdmin(session.user)) redirect("/"); const returnPath = nextPath.startsWith("/admin") && !isAuthorizedAdmin(session.user) ? "/" : nextPath;
if (mode === "register" && setup) {
const [user] = await getDb().select({ name: users.name, email: users.email, image: users.image })
.from(users).where(eq(users.id, session.user.id)).limit(1);
if (!user) redirect("/auth/login");
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
<div className="w-full max-w-sm"><ProfileForm {...user} nextPath={returnPath} setup /></div>
</main></div>;
}
if (returnPath !== nextPath) redirect(returnPath);
redirect(nextPath); redirect(nextPath);
} }
const siteKey = process.env.TURNSTILE_SITE_KEY ?? ""; const siteKey = process.env.TURNSTILE_SITE_KEY ?? "";
+11 -4
View File
@@ -4,7 +4,7 @@ import { useEffect, useState, type FormEvent } from "react";
import Link from "next/link"; import Link from "next/link";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Alert, AlertDescription } from "@/components/ui/alert"; import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
Card, Card,
CardContent, CardContent,
@@ -22,11 +22,13 @@ export function ProfileForm({
email, email,
image: initialImage, image: initialImage,
nextPath, nextPath,
setup = false,
}: { }: {
name: string; name: string;
email: string; email: string;
image: string | null; image: string | null;
nextPath?: string; nextPath?: string;
setup?: boolean;
}) { }) {
const [name, setName] = useState(initialName); const [name, setName] = useState(initialName);
const [file, setFile] = useState<File | null>(null); const [file, setFile] = useState<File | null>(null);
@@ -69,12 +71,16 @@ export function ProfileForm({
return ( return (
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>ข้อมูลบัญชี</CardTitle> <CardTitle>{setup ? "ปรับแต่งโปรไฟล์" : "ข้อมูลบัญชี"}</CardTitle>
{setup && <CardDescription>ขั้นตอน 3 จาก 3 - ปรับแต่งโปรไฟล์</CardDescription>}
<CardDescription>{email}</CardDescription> <CardDescription>{email}</CardDescription>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<form id="profile-form" onSubmit={save}> <form id="profile-form" onSubmit={save}>
<FieldGroup> <FieldGroup>
{setup && <p className="text-sm text-muted-foreground">
สร้างบัญชีเรียบร้อยแล้ว เพิ่มรูปโปรไฟล์ได้ตามต้องการ หรือกดข้ามเพื่อเริ่มใช้งาน คุณสามารถยืนยันอีเมลภายหลังได้ที่หน้าโปรไฟล์
</p>}
<Field> <Field>
<FieldLabel htmlFor="profile-image">รูปโปรไฟล์</FieldLabel> <FieldLabel htmlFor="profile-image">รูปโปรไฟล์</FieldLabel>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
@@ -127,9 +133,10 @@ export function ProfileForm({
form="profile-form" form="profile-form"
disabled={busy || name.trim().length < 2 || name.trim().length > 80} disabled={busy || name.trim().length < 2 || name.trim().length > 80}
> >
{busy ? "กำลังบันทึก..." : "บันทึกโปรไฟล์"} {busy ? "กำลังบันทึก..." : setup ? "บันทึกและเริ่มใช้งาน" : "บันทึกโปรไฟล์"}
</Button> </Button>
{nextPath && <Link href={nextPath} className={buttonVariants({ variant: "outline" })}>ข้ามและเริ่มใช้งาน</Link>} {nextPath && <Button variant="outline" nativeButton={false} disabled={busy}
render={<Link href={nextPath} />}>ข้ามและเริ่มใช้งาน</Button>}
</CardFooter> </CardFooter>
</Card> </Card>
); );
+1 -1
View File
@@ -33,7 +33,7 @@ export function RegistrationOtpForm({ email, nextPath, setup }: {
try { try {
const result = await authClient.emailOtp.verifyEmail({ email: address, otp }); const result = await authClient.emailOtp.verifyEmail({ email: address, otp });
if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง"); if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง");
const returnPath = setup ? `/profile?setup=1&next=${encodeURIComponent(nextPath)}` : nextPath; const returnPath = setup ? `/auth/register?step=profile&next=${encodeURIComponent(nextPath)}` : nextPath;
router.replace(returnPath); router.replace(returnPath);
router.refresh(); router.refresh();
} catch (cause) { } catch (cause) {
+10 -2
View File
@@ -74,13 +74,21 @@ describe("actual administrator session boundary", () => {
expect(await getCustomerSession()).toMatchObject({ user: { id: "user-1" } }); expect(await getCustomerSession()).toMatchObject({ user: { id: "user-1" } });
expect(await getAdminSession()).toMatchObject({ user: { id: "user-1" } }); expect(await getAdminSession()).toMatchObject({ user: { id: "user-1" } });
}); });
it("requires verification and sends auth links from the configured sender", async () => { it("allows an unverified customer session but rejects administrator access", async () => {
mocks.session.mockResolvedValue({ user: { id: "user-1", email: "[email protected]",
emailVerified: false, role: "admin" }, session: { id: "guide-session" } });
const { getAdminSession, getCustomerSession, requireAdmin } = await import("./server");
expect(await getCustomerSession()).toMatchObject({ user: { id: "user-1", emailVerified: false } });
expect(await getAdminSession()).toBeNull();
await expect(requireAdmin()).rejects.toMatchObject({ status: 401 });
});
it("allows unverified sign-in and sends auth links from the configured sender", async () => {
testEnv.RESEND_API_KEY = "test-key"; testEnv.RESEND_API_KEY = "test-key";
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 })); const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 200 }));
try { try {
(await import("./server")).getAuth(); (await import("./server")).getAuth();
const options = mocks.auth.mock.calls.at(-1)![0]; const options = mocks.auth.mock.calls.at(-1)![0];
expect(options.emailAndPassword.requireEmailVerification).toBe(true); expect(options.emailAndPassword).toMatchObject({ requireEmailVerification: false, autoSignIn: true });
expect(options.emailAndPassword.resetPasswordTokenExpiresIn).toBe(3600); expect(options.emailAndPassword.resetPasswordTokenExpiresIn).toBe(3600);
expect(options.emailVerification).toMatchObject({ expect(options.emailVerification).toMatchObject({
sendOnSignUp: false, autoSignInAfterVerification: true, expiresIn: 3600, sendOnSignUp: false, autoSignInAfterVerification: true, expiresIn: 3600,
+2 -1
View File
@@ -53,7 +53,8 @@ function createAuth() {
secret: required("BETTER_AUTH_SECRET"), secret: required("BETTER_AUTH_SECRET"),
emailAndPassword: { emailAndPassword: {
enabled: true, enabled: true,
requireEmailVerification: true, requireEmailVerification: false,
autoSignIn: true,
minPasswordLength: 6, minPasswordLength: 6,
maxPasswordLength: 128, maxPasswordLength: 128,
resetPasswordTokenExpiresIn: 3600, resetPasswordTokenExpiresIn: 3600,