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