feat(auth) : add optional email verification and profile setup
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user