diff --git a/app/api/profile/password/route.ts b/app/api/profile/password/route.ts new file mode 100644 index 0000000..c0b2c97 --- /dev/null +++ b/app/api/profile/password/route.ts @@ -0,0 +1,28 @@ +import { APIError } from "better-auth/api"; +import * as z from "zod"; +import { getAuth, getCustomerSession } from "@/lib/auth/server"; +import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; +import { limitRequest } from "@/lib/security/rate-limit"; + +export async function POST(request: Request) { + try { + requireSameOrigin(request); + const session = await getCustomerSession(); + if (!session) throw new HttpError(401, "unauthorized"); + await limitRequest("password-set", session.user.id, 5, 3600); + const parsed = z.object({ newPassword: z.string().min(6).max(128) }) + .safeParse(await readJson(request, 1024)); + if (!parsed.success) throw new HttpError(400, "invalid-password"); + await getAuth().api.setPassword({ headers: request.headers, body: parsed.data }); + return new Response(null, { status: 204, headers: { "Cache-Control": "no-store" } }); + } catch (cause) { + if (cause instanceof APIError) { + const error = typeof cause.body?.code === "string" ? cause.body.code : "password-setting-failed"; + return Response.json({ error }, { + status: cause.statusCode || 400, + headers: { "Cache-Control": "no-store" }, + }); + } + return errorResponse(cause); + } +} diff --git a/app/profile/page.tsx b/app/profile/page.tsx index bc3de8b..1f8955a 100644 --- a/app/profile/page.tsx +++ b/app/profile/page.tsx @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { redirect } from "next/navigation"; import { connection } from "next/server"; import { UserRoundIcon } from "lucide-react"; @@ -27,15 +27,17 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile" getDb().select({ name: users.name, email: users.email, emailVerified: users.emailVerified, image: users.image }) .from(users).where(eq(users.id, session.user.id)).limit(1), - getDb().select({ id: accounts.id, providerId: accounts.providerId }) + getDb().select({ id: accounts.id, providerId: accounts.providerId, + hasPassword: sql`${accounts.password} is not null` }) .from(accounts).where(eq(accounts.userId, session.user.id)), ]); const [user] = userRows; if (!user) redirect("/auth/login?next=%2Fprofile"); - const hasCredential = accountRows.some((account) => account.providerId === "credential"); + const hasPassword = accountRows.some((account) => account.providerId === "credential" && account.hasPassword); const googleAccountIds = accountRows.filter((account) => account.providerId === "google") .map((account) => account.id); - const hasOtherAuthMethod = accountRows.some((account) => account.providerId !== "google"); + const hasOtherAuthMethod = accountRows.some((account) => account.providerId !== "google" + && (account.providerId !== "credential" || account.hasPassword)); return
{isAuthorizedAdmin(session.user) ? : }

@@ -52,7 +54,7 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile" callbackCompleted={emailAction === "1" && !error} callbackError={emailAction === "1" && typeof error === "string"} /> {isGoogleAuthEnabled() && } - {hasCredential && } +

; diff --git a/components/auth/password-form.tsx b/components/auth/password-form.tsx index a66c3f9..0fa81bc 100644 --- a/components/auth/password-form.tsx +++ b/components/auth/password-form.tsx @@ -1,6 +1,7 @@ "use client"; import { useRef, useState, type FormEvent } from "react"; +import { useRouter } from "next/navigation"; import { KeyRoundIcon } from "lucide-react"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; @@ -9,11 +10,13 @@ import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; import { Input } from "@/components/ui/input"; import { authClient } from "@/lib/auth/client"; -export function PasswordForm() { +export function PasswordForm({ hasPassword: initialHasPassword }: { hasPassword: boolean }) { + const router = useRouter(); const formRef = useRef(null); + const [hasPassword, setHasPassword] = useState(initialHasPassword); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); - const [success, setSuccess] = useState(false); + const [success, setSuccess] = useState(""); async function changePassword(event: FormEvent) { event.preventDefault(); @@ -23,24 +26,38 @@ export function PasswordForm() { const newPassword = String(form.get("newPassword") ?? ""); const confirmPassword = String(form.get("confirmPassword") ?? ""); setError(""); - setSuccess(false); + setSuccess(""); if (newPassword !== confirmPassword) { - setError("รหัสผ่านใหม่ไม่ตรงกัน"); + setError(hasPassword ? "รหัสผ่านใหม่ไม่ตรงกัน" : "รหัสผ่านไม่ตรงกัน"); return; } setBusy(true); try { - const result = await authClient.changePassword({ currentPassword, newPassword }); - if (result.error) { - setError(result.error.code === "INVALID_PASSWORD" - ? "รหัสผ่านปัจจุบันไม่ถูกต้อง" - : "เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง"); - return; + if (hasPassword) { + const result = await authClient.changePassword({ currentPassword, newPassword }); + if (result.error) { + setError(result.error.code === "INVALID_PASSWORD" + ? "รหัสผ่านปัจจุบันไม่ถูกต้อง" + : "เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง"); + return; + } + } else { + const response = await fetch("/api/profile/password", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ newPassword }), + }); + if (!response.ok) { + setError("เพิ่มรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง"); + return; + } + setHasPassword(true); + router.refresh(); } formRef.current?.reset(); - setSuccess(true); + setSuccess(hasPassword ? "เปลี่ยนรหัสผ่านเรียบร้อยแล้ว" : "เพิ่มรหัสผ่านเรียบร้อยแล้ว"); } catch { - setError("เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง"); + setError(hasPassword ? "เปลี่ยนรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง" : "เพิ่มรหัสผ่านไม่สำเร็จ กรุณาลองอีกครั้ง"); } finally { setBusy(false); } @@ -50,36 +67,40 @@ export function PasswordForm() { - กรอกรหัสผ่านปัจจุบันและรหัสผ่านใหม่ + {hasPassword + ? "กรอกรหัสผ่านปัจจุบันและรหัสผ่านใหม่" + : "บัญชีนี้ยังไม่มีรหัสผ่าน เพิ่มรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลได้ด้วย"}
- + {hasPassword && รหัสผ่านปัจจุบัน - + } - รหัสผ่านใหม่ + {hasPassword ? "รหัสผ่านใหม่" : "รหัสผ่าน"} - ยืนยันรหัสผ่านใหม่ + {hasPassword ? "ยืนยันรหัสผ่านใหม่" : "ยืนยันรหัสผ่าน"} {error && {error}} - {success && เปลี่ยนรหัสผ่านเรียบร้อยแล้ว} + {success && {success}}
;