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
+7 -3
View File
@@ -168,7 +168,7 @@ export function AccountForm({
email: pendingEmail, otp: String(form.get("otp") ?? "").trim(),
});
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();
return;
}
@@ -199,7 +199,7 @@ export function AccountForm({
<CardHeader>
<CardTitle>{register ? pendingEmail ? "ยืนยันอีเมล" : "สร้างบัญชี Buzz Guide" : "เข้าสู่ระบบ"}</CardTitle>
{nextPath.startsWith("/commission") && <CardDescription>เข้าสู่ระบบเพื่อบันทึกคำขอ รับผล และพูดคุยกับทีมงาน</CardDescription>}
{register && <CardDescription>ขั้นตอน {pendingEmail ? "2 จาก 2 - ยืนยันอีเมล" : "1 จาก 2 - ข้อมูลบัญชี"}</CardDescription>}
{register && <CardDescription>ขั้นตอน {pendingEmail ? "2 จาก 3 - ยืนยันอีเมล" : "1 จาก 3 - ข้อมูลบัญชี"}</CardDescription>}
</CardHeader>
<CardContent className="flex flex-col gap-4">
{verified && <Alert><AlertDescription>ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ</AlertDescription></Alert>}
@@ -268,8 +268,12 @@ export function AccountForm({
</>}
{error && <Alert variant="destructive" role="alert"><AlertDescription>{error}</AlertDescription></Alert>}
<Button type="submit" disabled={busy || (captcha && !token && !pendingEmail)}>
{busy ? "กำลังดำเนินการ…" : register ? pendingEmail ? "ยืนยันและสร้างบัญชี" : "ถัดไป - ส่งรหัสยืนยัน" : "เข้าสู่ระบบ"}
{busy ? "กำลังดำเนินการ…" : register ? pendingEmail ? "ยืนยันอีเมล" : "ถัดไป - ส่งรหัสยืนยัน" : "เข้าสู่ระบบ"}
</Button>
{register && pendingEmail && <Button type="button" variant="outline" disabled={busy} onClick={() => {
router.replace(`/auth/register?step=profile&next=${encodeURIComponent(nextPath)}`);
router.refresh();
}}>ข้ามการยืนยันอีเมลตอนนี้</Button>}
</FieldGroup>
</form>
{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 { eq } from "drizzle-orm";
import { getDb } from "@/db";
import { users } from "@/db/schema";
import { SiteHeader } from "@/components/public/site-header";
import { isAuthorizedAdmin } from "@/lib/auth/authorization";
import { getCustomerSession } from "@/lib/auth/server";
import { safeAuthReturnPath } from "@/lib/auth/return-path";
import { AccountForm } from "./account-form";
import { ProfileForm } from "./profile-form";
export async function AccountPage({ mode, next, verified, verificationError }: {
mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean;
export async function AccountPage({ mode, next, verified, verificationError, setup = false }: {
mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean; setup?: boolean;
}) {
const nextPath = safeAuthReturnPath(next);
const session = await getCustomerSession();
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);
}
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 { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Button, buttonVariants } from "@/components/ui/button";
import { Button } from "@/components/ui/button";
import {
Card,
CardContent,
@@ -22,11 +22,13 @@ export function ProfileForm({
email,
image: initialImage,
nextPath,
setup = false,
}: {
name: string;
email: string;
image: string | null;
nextPath?: string;
setup?: boolean;
}) {
const [name, setName] = useState(initialName);
const [file, setFile] = useState<File | null>(null);
@@ -69,12 +71,16 @@ export function ProfileForm({
return (
<Card>
<CardHeader>
<CardTitle>ข้อมูลบัญชี</CardTitle>
<CardTitle>{setup ? "ปรับแต่งโปรไฟล์" : "ข้อมูลบัญชี"}</CardTitle>
{setup && <CardDescription>ขั้นตอน 3 จาก 3 - ปรับแต่งโปรไฟล์</CardDescription>}
<CardDescription>{email}</CardDescription>
</CardHeader>
<CardContent>
<form id="profile-form" onSubmit={save}>
<FieldGroup>
{setup && <p className="text-sm text-muted-foreground">
สร้างบัญชีเรียบร้อยแล้ว เพิ่มรูปโปรไฟล์ได้ตามต้องการ หรือกดข้ามเพื่อเริ่มใช้งาน คุณสามารถยืนยันอีเมลภายหลังได้ที่หน้าโปรไฟล์
</p>}
<Field>
<FieldLabel htmlFor="profile-image">รูปโปรไฟล์</FieldLabel>
<div className="flex items-center gap-4">
@@ -127,9 +133,10 @@ export function ProfileForm({
form="profile-form"
disabled={busy || name.trim().length < 2 || name.trim().length > 80}
>
{busy ? "กำลังบันทึก..." : "บันทึกโปรไฟล์"}
{busy ? "กำลังบันทึก..." : setup ? "บันทึกและเริ่มใช้งาน" : "บันทึกโปรไฟล์"}
</Button>
{nextPath && <Link href={nextPath} className={buttonVariants({ variant: "outline" })}>ข้ามและเริ่มใช้งาน</Link>}
{nextPath && <Button variant="outline" nativeButton={false} disabled={busy}
render={<Link href={nextPath} />}>ข้ามและเริ่มใช้งาน</Button>}
</CardFooter>
</Card>
);
+1 -1
View File
@@ -33,7 +33,7 @@ export function RegistrationOtpForm({ email, nextPath, setup }: {
try {
const result = await authClient.emailOtp.verifyEmail({ email: address, otp });
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.refresh();
} catch (cause) {