feat : update
This commit is contained in:
@@ -10,20 +10,41 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
turnstile?: {
|
||||
render(container: HTMLElement, options: {
|
||||
sitekey: string;
|
||||
callback: (token: string) => void;
|
||||
"expired-callback": () => void;
|
||||
"error-callback": () => void;
|
||||
}): string;
|
||||
reset(widgetId: string): void;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function AccountForm({
|
||||
mode,
|
||||
siteKey,
|
||||
nextPath,
|
||||
verified,
|
||||
verificationError,
|
||||
}: {
|
||||
mode: "login" | "register";
|
||||
siteKey: string;
|
||||
nextPath: string;
|
||||
verified?: boolean;
|
||||
verificationError?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const register = mode === "register";
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [pendingEmail, setPendingEmail] = useState("");
|
||||
const [notice, setNotice] = useState("");
|
||||
const [token, setToken] = useState("");
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const widget = useRef<string | null>(null);
|
||||
const captcha = process.env.NODE_ENV !== "development";
|
||||
@@ -48,47 +69,40 @@ export function AccountForm({
|
||||
};
|
||||
}, [captcha, siteKey]);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (captcha && !token) return;
|
||||
async function sendRegistrationOtp() {
|
||||
if (!register || busy) return;
|
||||
if (pendingEmail) {
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setNotice("");
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({
|
||||
email: pendingEmail, type: "email-verification",
|
||||
});
|
||||
if (result.error) throw new Error("ส่งรหัสใหม่ไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
setNotice("ส่งรหัสใหม่แล้ว กรุณาตรวจสอบอีเมลของคุณ");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "ส่งรหัสใหม่ไม่สำเร็จ");
|
||||
} finally { setBusy(false); }
|
||||
return;
|
||||
}
|
||||
const form = formRef.current;
|
||||
if (!form?.reportValidity() || (captcha && !token)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
const form = new FormData(event.currentTarget);
|
||||
const email = String(form.get("email") ?? "").trim();
|
||||
const password = String(form.get("password") ?? "");
|
||||
const image = register ? form.get("image") : null;
|
||||
const fetchOptions = {
|
||||
headers: captcha ? { "x-captcha-response": token } : undefined,
|
||||
};
|
||||
setNotice("");
|
||||
try {
|
||||
if (image instanceof File && image.size > 0 &&
|
||||
(image.size > 6 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(image.type))) {
|
||||
throw new Error("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
|
||||
}
|
||||
const result = register
|
||||
? await authClient.signUp.email({
|
||||
name: String(form.get("name") ?? "").trim(),
|
||||
email,
|
||||
password,
|
||||
fetchOptions,
|
||||
})
|
||||
: await authClient.signIn.email({ email, password, fetchOptions });
|
||||
if (result.error)
|
||||
throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ");
|
||||
if (register && image instanceof File && image.size > 0) {
|
||||
const profile = new FormData();
|
||||
profile.set("name", String(form.get("name") ?? "").trim());
|
||||
profile.set("image", image);
|
||||
const uploaded = await fetch("/api/profile", { method: "POST", body: profile })
|
||||
.then((response) => response.ok).catch(() => false);
|
||||
if (!uploaded) {
|
||||
router.push(`/profile?setup=1&upload=failed&next=${encodeURIComponent(nextPath)}`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
}
|
||||
router.push(nextPath);
|
||||
router.refresh();
|
||||
const fields = new FormData(form);
|
||||
const email = String(fields.get("email") ?? "").trim();
|
||||
const result = await authClient.signUp.email({
|
||||
name: String(fields.get("name") ?? "").trim(),
|
||||
email,
|
||||
password: String(fields.get("password") ?? ""),
|
||||
fetchOptions: { headers: captcha ? { "x-captcha-response": token } : undefined },
|
||||
});
|
||||
if (result.error) throw new Error(result.error.message ?? "ส่งรหัสไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
setPendingEmail(email);
|
||||
setNotice("ส่งรหัส 6 หลักแล้ว กรุณาตรวจสอบอีเมลและกรอกรหัสภายใน 5 นาที");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง");
|
||||
setToken("");
|
||||
@@ -98,13 +112,59 @@ export function AccountForm({
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (register && !pendingEmail) return sendRegistrationOtp();
|
||||
if (busy || (!register && captcha && !token)) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (register) {
|
||||
const form = new FormData(event.currentTarget);
|
||||
const result = await authClient.emailOtp.verifyEmail({
|
||||
email: pendingEmail, otp: String(form.get("otp") ?? "").trim(),
|
||||
});
|
||||
if (result.error) throw new Error("รหัสไม่ถูกต้องหรือหมดอายุ กรุณาลองอีกครั้ง");
|
||||
const setupPath = `/profile?setup=1&next=${encodeURIComponent(nextPath)}`;
|
||||
router.push(`/login?verified=1&next=${encodeURIComponent(setupPath)}`);
|
||||
router.refresh();
|
||||
return;
|
||||
}
|
||||
const form = new FormData(event.currentTarget);
|
||||
const email = String(form.get("email") ?? "").trim();
|
||||
const result = await authClient.signIn.email({
|
||||
email, password: String(form.get("password") ?? ""),
|
||||
fetchOptions: { headers: captcha ? { "x-captcha-response": token } : undefined },
|
||||
});
|
||||
if (result.error?.code === "EMAIL_NOT_VERIFIED") {
|
||||
router.push(`/check-email?email=${encodeURIComponent(email)}&next=${encodeURIComponent(nextPath)}`);
|
||||
return;
|
||||
}
|
||||
if (result.error) throw new Error(result.error.message ?? "เข้าสู่ระบบไม่สำเร็จ");
|
||||
router.push(nextPath);
|
||||
router.refresh();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง");
|
||||
if (!register) {
|
||||
setToken("");
|
||||
if (widget.current) window.turnstile?.reset(widget.current);
|
||||
}
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="w-full max-w-sm">
|
||||
<CardHeader>
|
||||
<CardTitle>{register ? "สมัครสมาชิก" : "เข้าสู่ระบบ"}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<form onSubmit={submit}>
|
||||
{verified && <Alert><AlertDescription>ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ</AlertDescription></Alert>}
|
||||
{verificationError && <Alert variant="destructive"><AlertDescription>ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่</AlertDescription></Alert>}
|
||||
{notice && <Alert><AlertDescription>{notice}</AlertDescription></Alert>}
|
||||
{register && !pendingEmail && <p className="text-sm text-muted-foreground">
|
||||
กรอกชื่อ อีเมล และรหัสผ่าน แล้วกดส่ง OTP
|
||||
</p>}
|
||||
<form ref={formRef} onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
{register && (
|
||||
<Field>
|
||||
@@ -116,24 +176,27 @@ export function AccountForm({
|
||||
minLength={2}
|
||||
maxLength={80}
|
||||
autoComplete="name"
|
||||
disabled={busy || Boolean(pendingEmail)}
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
{register && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor="register-image">รูปโปรไฟล์ (ไม่บังคับ)</FieldLabel>
|
||||
<Input id="register-image" name="image" type="file" accept="image/png,image/jpeg,image/webp" />
|
||||
</Field>
|
||||
)}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="email">อีเมล</FieldLabel>
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
required
|
||||
autoComplete="email"
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="email"
|
||||
name="email"
|
||||
type="email"
|
||||
className="min-w-0 flex-1"
|
||||
required
|
||||
autoComplete="email"
|
||||
disabled={busy || Boolean(pendingEmail)}
|
||||
/>
|
||||
{register && <Button type="button" variant="outline" className="shrink-0"
|
||||
onClick={sendRegistrationOtp} disabled={busy || (captcha && !token && !pendingEmail)}>
|
||||
{busy ? "กำลังส่ง..." : pendingEmail ? "ส่งอีกครั้ง" : "ส่ง OTP"}
|
||||
</Button>}
|
||||
</div>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="password">รหัสผ่าน</FieldLabel>
|
||||
@@ -144,29 +207,38 @@ export function AccountForm({
|
||||
required
|
||||
minLength={6}
|
||||
autoComplete={register ? "new-password" : "current-password"}
|
||||
disabled={busy || Boolean(pendingEmail)}
|
||||
/>
|
||||
</Field>
|
||||
{captcha && <div ref={container} />}
|
||||
<Button type="submit" disabled={busy || (captcha && !token)}>
|
||||
{register && <Field>
|
||||
<FieldLabel htmlFor="otp">รหัส OTP</FieldLabel>
|
||||
<Input id="otp" name="otp" inputMode="numeric" autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}" minLength={6} maxLength={6} required={Boolean(pendingEmail)}
|
||||
placeholder="กดส่ง OTP ก่อน" disabled={busy || !pendingEmail} />
|
||||
</Field>}
|
||||
{captcha && !pendingEmail && <div ref={container} />}
|
||||
{(!register || pendingEmail) && <Button type="submit" disabled={busy || (!register && captcha && !token)}>
|
||||
{busy
|
||||
? "กำลังดำเนินการ..."
|
||||
: register
|
||||
? "สมัครสมาชิก"
|
||||
? "ยืนยันอีเมล"
|
||||
: "เข้าสู่ระบบ"}
|
||||
</Button>
|
||||
</Button>}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<Button
|
||||
variant="link"
|
||||
nativeButton={false}
|
||||
render={
|
||||
<Link
|
||||
href={`${register ? "/login" : "/register"}${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{register ? "มีบัญชีแล้ว? เข้าสู่ระบบ" : "ยังไม่มีบัญชี? สมัครสมาชิก"}
|
||||
</Button>
|
||||
{register ? <Button variant="link" nativeButton={false}
|
||||
render={<Link href={`/login${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
|
||||
มีบัญชีแล้ว? เข้าสู่ระบบ
|
||||
</Button> : <div className="flex items-center justify-between gap-2">
|
||||
<Button variant="link" size="sm" className="px-0" nativeButton={false}
|
||||
render={<Link href={`/register${nextPath === "/" ? "" : `?next=${encodeURIComponent(nextPath)}`}`} />}>
|
||||
สมัครสมาชิก
|
||||
</Button>
|
||||
<Button variant="link" size="sm" className="px-0" nativeButton={false}
|
||||
render={<Link href="/forgot-password" />}>
|
||||
ลืมรหัสผ่าน?
|
||||
</Button>
|
||||
</div>}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{error}</AlertDescription>
|
||||
|
||||
@@ -17,7 +17,7 @@ export function AccountMenu({ admin = false }: { admin?: boolean }) {
|
||||
async function signOut() {
|
||||
await unsubscribeCommissionPush().catch(() => undefined);
|
||||
await authClient.signOut();
|
||||
router.push(admin ? "/admin/login" : "/commission");
|
||||
router.push(admin ? "/login?next=%2Fadmin" : "/commission");
|
||||
router.refresh();
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import { redirect } from "next/navigation";
|
||||
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";
|
||||
|
||||
export async function AccountPage({ mode, next }: { mode: "login" | "register"; next: unknown }) {
|
||||
export async function AccountPage({ mode, next, verified, verificationError }: {
|
||||
mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean;
|
||||
}) {
|
||||
const nextPath = safeAuthReturnPath(next);
|
||||
if (await getCustomerSession()) redirect(nextPath);
|
||||
const session = await getCustomerSession();
|
||||
if (session) {
|
||||
if (nextPath.startsWith("/admin") && !isAuthorizedAdmin(session.user)) redirect("/");
|
||||
redirect(nextPath);
|
||||
}
|
||||
const siteKey = process.env.TURNSTILE_SITE_KEY ?? "";
|
||||
if (process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required");
|
||||
return <div className="min-h-svh"><SiteHeader /><main className="grid min-h-[calc(100svh-4rem)] place-items-center p-4">
|
||||
<AccountForm mode={mode} siteKey={siteKey} nextPath={nextPath} />
|
||||
<AccountForm mode={mode} siteKey={siteKey} nextPath={nextPath}
|
||||
verified={verified} verificationError={verificationError} />
|
||||
</main></div>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
type Mode = "forgot" | "reset";
|
||||
|
||||
export function EmailActionForm({ mode, token = "", invalidToken = false }: {
|
||||
mode: Mode; token?: string; invalidToken?: boolean;
|
||||
}) {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [sent, setSent] = useState(false);
|
||||
|
||||
async function submit(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
const address = String(form.get("email") ?? "").trim();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
if (mode === "forgot") {
|
||||
const result = await authClient.requestPasswordReset({ email: address, redirectTo: "/reset-password" });
|
||||
if (result.error) throw new Error("ส่งลิงก์รีเซ็ตไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
} else {
|
||||
const password = String(form.get("password") ?? "");
|
||||
if (password !== String(form.get("confirmPassword") ?? "")) {
|
||||
setError("รหัสผ่านใหม่ไม่ตรงกัน");
|
||||
return;
|
||||
}
|
||||
const result = await authClient.resetPassword({ newPassword: password, token });
|
||||
if (result.error) throw new Error("รีเซ็ตรหัสผ่านไม่สำเร็จ ลิงก์อาจหมดอายุแล้ว");
|
||||
}
|
||||
setSent(true);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "ลองอีกครั้ง");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
const title = mode === "forgot" ? "ลืมรหัสผ่าน" : "ตั้งรหัสผ่านใหม่";
|
||||
return <Card className="w-full max-w-sm">
|
||||
<CardHeader><CardTitle>{title}</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{sent && <Alert><AlertDescription>{mode === "reset"
|
||||
? "ตั้งรหัสผ่านใหม่แล้ว กรุณาเข้าสู่ระบบ"
|
||||
: "หากอีเมลนี้มีบัญชีอยู่ โปรดตรวจสอบกล่องจดหมายและโฟลเดอร์สแปม"}</AlertDescription></Alert>}
|
||||
{invalidToken && <Alert variant="destructive"><AlertDescription>
|
||||
ลิงก์รีเซ็ตไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่
|
||||
</AlertDescription></Alert>}
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
{(mode !== "reset" || (!sent && token)) && <form onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
{mode !== "reset" ? <Field>
|
||||
<FieldLabel htmlFor="auth-email">อีเมล</FieldLabel>
|
||||
<Input id="auth-email" name="email" type="email" autoComplete="email" required disabled={busy} />
|
||||
</Field> : <>
|
||||
<Field><FieldLabel htmlFor="new-password">รหัสผ่านใหม่</FieldLabel>
|
||||
<Input id="new-password" name="password" type="password" minLength={6} maxLength={128}
|
||||
autoComplete="new-password" required disabled={busy} /></Field>
|
||||
<Field><FieldLabel htmlFor="confirm-password">ยืนยันรหัสผ่านใหม่</FieldLabel>
|
||||
<Input id="confirm-password" name="confirmPassword" type="password" minLength={6} maxLength={128}
|
||||
autoComplete="new-password" required disabled={busy} /></Field>
|
||||
</>}
|
||||
<Button type="submit" disabled={busy}>{busy ? "กำลังดำเนินการ..." :
|
||||
mode === "forgot" ? "ส่งลิงก์รีเซ็ต" : "ตั้งรหัสผ่าน"}</Button>
|
||||
</FieldGroup>
|
||||
</form>}
|
||||
{mode === "reset" && !token && <Button variant="link" nativeButton={false} render={<Link href="/forgot-password" />}>
|
||||
ขอลิงก์รีเซ็ตใหม่
|
||||
</Button>}
|
||||
<Button variant="link" nativeButton={false} render={<Link href="/login" />}>กลับไปเข้าสู่ระบบ</Button>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
"use client";
|
||||
|
||||
import { useState, type FormEvent } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export function EmailSettings({ email, verified, callbackCompleted, callbackError }: {
|
||||
email: string; verified: boolean; callbackCompleted: boolean; callbackError: boolean;
|
||||
}) {
|
||||
const [newEmail, setNewEmail] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [message, setMessage] = useState("");
|
||||
|
||||
async function requestVerification() {
|
||||
if (sending) return;
|
||||
setSending(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await authClient.sendVerificationEmail({ email, callbackURL: "/profile?emailAction=1" });
|
||||
if (result.error) throw new Error("ส่งลิงก์ยืนยันไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
setMessage("ส่งลิงก์ยืนยันแล้ว กรุณาตรวจสอบอีเมลของคุณ");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "ส่งลิงก์ยืนยันไม่สำเร็จ");
|
||||
} finally { setSending(false); }
|
||||
}
|
||||
|
||||
async function changeEmail(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
setMessage("");
|
||||
try {
|
||||
const result = await authClient.changeEmail({ newEmail: newEmail.trim(), callbackURL: "/profile?emailAction=1" });
|
||||
if (result.error?.code === "SESSION_NOT_FRESH")
|
||||
throw new Error("กรุณาออกจากระบบและเข้าสู่ระบบอีกครั้งก่อนเปลี่ยนอีเมล");
|
||||
if (result.error) throw new Error("เปลี่ยนอีเมลไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
setNewEmail("");
|
||||
setMessage(verified
|
||||
? "ตรวจสอบอีเมลปัจจุบันเพื่ออนุมัติ แล้วตรวจสอบอีเมลใหม่เพื่อยืนยัน"
|
||||
: "ตรวจสอบอีเมลใหม่เพื่อยืนยันการเปลี่ยนแปลง");
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "เปลี่ยนอีเมลไม่สำเร็จ");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
return <Card>
|
||||
<CardHeader><CardTitle>อีเมล</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
<p className="text-sm">{email} · {verified ? "ยืนยันแล้ว" : "ยังไม่ยืนยัน"}</p>
|
||||
{callbackCompleted && <Alert><AlertDescription>ยืนยันขั้นตอนแล้ว หากกำลังเปลี่ยนอีเมล โปรดตรวจสอบกล่องจดหมายสำหรับขั้นตอนถัดไป</AlertDescription></Alert>}
|
||||
{callbackError && <Alert variant="destructive"><AlertDescription>ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่</AlertDescription></Alert>}
|
||||
{message && <Alert><AlertDescription>{message}</AlertDescription></Alert>}
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
<form id="change-email-form" onSubmit={changeEmail}>
|
||||
<FieldGroup><Field>
|
||||
<FieldLabel htmlFor="new-email">อีเมลใหม่</FieldLabel>
|
||||
<Input id="new-email" type="email" value={newEmail} onChange={(event) => setNewEmail(event.target.value)}
|
||||
autoComplete="email" required disabled={busy || sending} />
|
||||
</Field></FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter className="flex-wrap gap-2">
|
||||
<Button type="submit" form="change-email-form" disabled={busy || sending || !newEmail.trim() || newEmail.trim().toLowerCase() === email.toLowerCase()}>
|
||||
{busy ? "กำลังดำเนินการ..." : "เปลี่ยนอีเมล"}
|
||||
</Button>
|
||||
{!verified && <Button variant="outline" onClick={requestVerification} disabled={busy || sending}>
|
||||
{sending ? "กำลังส่ง..." : "ขอลิงก์ยืนยันอีเมล"}
|
||||
</Button>}
|
||||
</CardFooter>
|
||||
</Card>;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export function PasswordForm() {
|
||||
const formRef = useRef<HTMLFormElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [success, setSuccess] = useState(false);
|
||||
|
||||
async function changePassword(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
const currentPassword = String(form.get("currentPassword") ?? "");
|
||||
const newPassword = String(form.get("newPassword") ?? "");
|
||||
const confirmPassword = String(form.get("confirmPassword") ?? "");
|
||||
setError("");
|
||||
setSuccess(false);
|
||||
if (newPassword !== confirmPassword) {
|
||||
setError("รหัสผ่านใหม่ไม่ตรงกัน");
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
try {
|
||||
const result = await authClient.changePassword({ currentPassword, newPassword });
|
||||
if (result.error) {
|
||||
setError(result.error.code === "INVALID_PASSWORD"
|
||||
? "รหัสผ่านปัจจุบันไม่ถูกต้อง"
|
||||
: "เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
return;
|
||||
}
|
||||
formRef.current?.reset();
|
||||
setSuccess(true);
|
||||
} catch {
|
||||
setError("เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <Card>
|
||||
<CardHeader>
|
||||
<CardTitle>เปลี่ยนรหัสผ่าน</CardTitle>
|
||||
<CardDescription>กรอกรหัสผ่านปัจจุบันและรหัสผ่านใหม่</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="password-form" ref={formRef} onSubmit={changePassword}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="current-password">รหัสผ่านปัจจุบัน</FieldLabel>
|
||||
<Input id="current-password" name="currentPassword" type="password"
|
||||
autoComplete="current-password" required disabled={busy} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-password">รหัสผ่านใหม่</FieldLabel>
|
||||
<Input id="new-password" name="newPassword" type="password"
|
||||
autoComplete="new-password" minLength={6} maxLength={128} required disabled={busy} />
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor="confirm-password">ยืนยันรหัสผ่านใหม่</FieldLabel>
|
||||
<Input id="confirm-password" name="confirmPassword" type="password"
|
||||
autoComplete="new-password" minLength={6} maxLength={128} required disabled={busy} />
|
||||
</Field>
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
{success && <Alert><AlertDescription>เปลี่ยนรหัสผ่านเรียบร้อยแล้ว</AlertDescription></Alert>}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" form="password-form" disabled={busy}>
|
||||
{busy ? "กำลังเปลี่ยนรหัสผ่าน..." : "เปลี่ยนรหัสผ่าน"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>;
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
} from "@/components/ui/card";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { ProfileImageInput } from "./profile-image-input";
|
||||
|
||||
export function ProfileForm({
|
||||
name: initialName,
|
||||
@@ -40,20 +41,6 @@ export function ProfileForm({
|
||||
[preview],
|
||||
);
|
||||
|
||||
function chooseFile(next: File | null) {
|
||||
setError("");
|
||||
if (
|
||||
next &&
|
||||
(next.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(next.type))
|
||||
) {
|
||||
setError("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
|
||||
return;
|
||||
}
|
||||
setFile(next);
|
||||
setPreview(next ? URL.createObjectURL(next) : null);
|
||||
}
|
||||
|
||||
async function save(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
@@ -102,22 +89,14 @@ export function ProfileForm({
|
||||
{name.trim().charAt(0).toUpperCase() || "?"}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Input
|
||||
<ProfileImageInput
|
||||
id="profile-image"
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/webp"
|
||||
onChange={(event) => {
|
||||
const selected = event.target.files?.[0] ?? null;
|
||||
chooseFile(selected);
|
||||
if (
|
||||
selected &&
|
||||
(selected.size > 6 * 1024 * 1024 ||
|
||||
!["image/png", "image/jpeg", "image/webp"].includes(
|
||||
selected.type,
|
||||
))
|
||||
)
|
||||
event.target.value = "";
|
||||
onChange={(cropped) => {
|
||||
setFile(cropped);
|
||||
setPreview(URL.createObjectURL(cropped));
|
||||
setError("");
|
||||
}}
|
||||
onError={setError}
|
||||
disabled={busy}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Cropper, CropperCropArea, CropperDescription, CropperImage } from "@/components/ui/cropper";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
|
||||
type CropArea = { x: number; y: number; width: number; height: number };
|
||||
|
||||
export function ProfileImageInput({ id, disabled, onChange, onError }: {
|
||||
id: string;
|
||||
disabled?: boolean;
|
||||
onChange: (file: File) => void;
|
||||
onError: (message: string) => void;
|
||||
}) {
|
||||
const [pending, setPending] = useState<{ url: string } | null>(null);
|
||||
const [cropArea, setCropArea] = useState<CropArea | null>(null);
|
||||
const [cropping, setCropping] = useState(false);
|
||||
|
||||
useEffect(() => () => {
|
||||
if (pending) URL.revokeObjectURL(pending.url);
|
||||
}, [pending]);
|
||||
|
||||
function select(file: File | null) {
|
||||
if (!file) return;
|
||||
if (file.size > 6 * 1024 * 1024 || !["image/png", "image/jpeg", "image/webp"].includes(file.type)) {
|
||||
onError("ใช้รูป PNG, JPEG หรือ WebP ขนาดไม่เกิน 6 MB");
|
||||
return;
|
||||
}
|
||||
onError("");
|
||||
setCropArea(null);
|
||||
setPending({ url: URL.createObjectURL(file) });
|
||||
}
|
||||
|
||||
async function applyCrop() {
|
||||
if (!pending || !cropArea || cropping) return;
|
||||
setCropping(true);
|
||||
try {
|
||||
const image = await new Promise<HTMLImageElement>((resolve, reject) => {
|
||||
const candidate = new Image();
|
||||
candidate.onload = () => resolve(candidate);
|
||||
candidate.onerror = reject;
|
||||
candidate.src = pending.url;
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
canvas.width = 256;
|
||||
canvas.height = 256;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas unavailable");
|
||||
context.imageSmoothingQuality = "high";
|
||||
context.drawImage(image, cropArea.x, cropArea.y, cropArea.width, cropArea.height, 0, 0, 256, 256);
|
||||
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, "image/png"));
|
||||
if (!blob) throw new Error("Image encoding failed");
|
||||
onChange(new File([blob], "profile.png", { type: "image/png" }));
|
||||
setPending(null);
|
||||
} catch {
|
||||
onError("ไม่สามารถครอปไฟล์ภาพนี้ได้");
|
||||
} finally {
|
||||
setCropping(false);
|
||||
}
|
||||
}
|
||||
|
||||
return <>
|
||||
<Input id={id} type="file" accept="image/png,image/jpeg,image/webp" disabled={disabled}
|
||||
onChange={(event) => {
|
||||
select(event.target.files?.[0] ?? null);
|
||||
event.target.value = "";
|
||||
}} />
|
||||
<Dialog open={!!pending} onOpenChange={(open) => { if (!open && !cropping) setPending(null); }}>
|
||||
<DialogContent className="sm:max-w-xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>ครอปรูปโปรไฟล์ 1:1</DialogTitle>
|
||||
<DialogDescription>ลากภาพเพื่อเลือกส่วนที่ต้องการใช้เป็นรูปโปรไฟล์</DialogDescription>
|
||||
</DialogHeader>
|
||||
{pending && <Cropper key={pending.url} image={pending.url} aspectRatio={1}
|
||||
onCropChange={setCropArea} className="h-80 rounded-lg bg-black sm:h-96">
|
||||
<CropperDescription>ลากภาพหรือใช้ปุ่มลูกศรเพื่อเลือกพื้นที่ครอปสี่เหลี่ยม</CropperDescription>
|
||||
<CropperImage />
|
||||
<CropperCropArea />
|
||||
</Cropper>}
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" disabled={cropping} onClick={() => setPending(null)}>ยกเลิก</Button>
|
||||
<Button type="button" disabled={!cropArea || cropping} onClick={() => void applyCrop()}>
|
||||
{cropping ? "กำลังครอป..." : "ใช้ภาพนี้"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useRef, useState, type FormEvent } from "react";
|
||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Field, FieldGroup, FieldLabel } from "@/components/ui/field";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
|
||||
export function RegistrationOtpForm({ email, nextPath, setup }: {
|
||||
email: string; nextPath: string; setup: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const emailRef = useRef<HTMLInputElement>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [sent, setSent] = useState(setup);
|
||||
|
||||
async function verify(event: FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
if (busy) return;
|
||||
const form = new FormData(event.currentTarget);
|
||||
const address = String(form.get("email") ?? "").trim();
|
||||
const otp = String(form.get("otp") ?? "").trim();
|
||||
setBusy(true);
|
||||
setError("");
|
||||
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;
|
||||
router.push(`/login?verified=1&next=${encodeURIComponent(returnPath)}`);
|
||||
router.refresh();
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "ยืนยันอีเมลไม่สำเร็จ");
|
||||
} finally { setBusy(false); }
|
||||
}
|
||||
|
||||
async function resend() {
|
||||
if (sending || busy) return;
|
||||
const address = emailRef.current?.value.trim() ?? "";
|
||||
if (!address) return;
|
||||
setSending(true);
|
||||
setError("");
|
||||
try {
|
||||
const result = await authClient.emailOtp.sendVerificationOtp({ email: address, type: "email-verification" });
|
||||
if (result.error) throw new Error("ส่งรหัสใหม่ไม่สำเร็จ กรุณาลองอีกครั้ง");
|
||||
setSent(true);
|
||||
} catch (cause) {
|
||||
setError(cause instanceof Error ? cause.message : "ส่งรหัสใหม่ไม่สำเร็จ");
|
||||
} finally { setSending(false); }
|
||||
}
|
||||
|
||||
return <Card className="w-full max-w-sm">
|
||||
<CardHeader><CardTitle>ยืนยันอีเมล</CardTitle></CardHeader>
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{sent && <Alert><AlertDescription>กรอกรหัส 6 หลักที่ส่งไปยังอีเมลของคุณ รหัสหมดอายุใน 5 นาที</AlertDescription></Alert>}
|
||||
{!sent && <p className="text-sm text-muted-foreground">กรอกรหัสจากอีเมล หรือกดส่งรหัสใหม่หากยังไม่มีรหัส</p>}
|
||||
{error && <Alert variant="destructive"><AlertDescription>{error}</AlertDescription></Alert>}
|
||||
<form onSubmit={verify}>
|
||||
<FieldGroup>
|
||||
<Field><FieldLabel htmlFor="otp-email">อีเมล</FieldLabel>
|
||||
<Input ref={emailRef} id="otp-email" name="email" type="email" defaultValue={email} autoComplete="email" required disabled={busy || sending} />
|
||||
</Field>
|
||||
<Field><FieldLabel htmlFor="otp-code">รหัส OTP</FieldLabel>
|
||||
<Input id="otp-code" name="otp" inputMode="numeric" autoComplete="one-time-code"
|
||||
pattern="[0-9]{6}" minLength={6} maxLength={6} required disabled={busy || sending} />
|
||||
</Field>
|
||||
<Button type="submit" disabled={busy || sending}>{busy ? "กำลังยืนยัน..." : "ยืนยันอีเมล"}</Button>
|
||||
</FieldGroup>
|
||||
</form>
|
||||
<Button variant="link" onClick={resend} disabled={busy || sending}>
|
||||
{sending ? "กำลังส่ง..." : "ส่งรหัสใหม่"}
|
||||
</Button>
|
||||
<Button variant="link" nativeButton={false} render={<Link href="/login" />}>กลับไปเข้าสู่ระบบ</Button>
|
||||
</CardContent>
|
||||
</Card>;
|
||||
}
|
||||
Reference in New Issue
Block a user