feat(auth) : let Google-only users add a password
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<boolean>`${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 <div className="min-h-svh">{isAuthorizedAdmin(session.user) ? <AdminHeader /> : <SiteHeader />}
|
||||
<main className="mx-auto max-w-5xl p-4 py-10 sm:p-8">
|
||||
<h1 className="mb-6 flex items-center gap-2 font-heading text-3xl font-semibold">
|
||||
@@ -52,7 +54,7 @@ export default async function ProfilePage({ searchParams }: PageProps<"/profile"
|
||||
callbackCompleted={emailAction === "1" && !error} callbackError={emailAction === "1" && typeof error === "string"} />
|
||||
{isGoogleAuthEnabled() && <GoogleAccountSettings googleAccountIds={googleAccountIds}
|
||||
hasOtherAuthMethod={hasOtherAuthMethod} linkError={googleLinkError === "1"} />}
|
||||
{hasCredential && <PasswordForm />}
|
||||
<PasswordForm hasPassword={hasPassword} />
|
||||
</div>
|
||||
</main>
|
||||
</div>;
|
||||
|
||||
@@ -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<HTMLFormElement>(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<HTMLFormElement>) {
|
||||
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() {
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<KeyRoundIcon aria-hidden="true" className="size-4 shrink-0" />
|
||||
เปลี่ยนรหัสผ่าน
|
||||
{hasPassword ? "เปลี่ยนรหัสผ่าน" : "เพิ่มรหัสผ่าน"}
|
||||
</CardTitle>
|
||||
<CardDescription>กรอกรหัสผ่านปัจจุบันและรหัสผ่านใหม่</CardDescription>
|
||||
<CardDescription>{hasPassword
|
||||
? "กรอกรหัสผ่านปัจจุบันและรหัสผ่านใหม่"
|
||||
: "บัญชีนี้ยังไม่มีรหัสผ่าน เพิ่มรหัสผ่านเพื่อเข้าสู่ระบบด้วยอีเมลได้ด้วย"}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form id="password-form" ref={formRef} onSubmit={changePassword}>
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
{hasPassword && <Field>
|
||||
<FieldLabel htmlFor="current-password">รหัสผ่านปัจจุบัน</FieldLabel>
|
||||
<Input id="current-password" name="currentPassword" type="password"
|
||||
autoComplete="current-password" required disabled={busy} />
|
||||
</Field>
|
||||
</Field>}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="new-password">รหัสผ่านใหม่</FieldLabel>
|
||||
<FieldLabel htmlFor="new-password">{hasPassword ? "รหัสผ่านใหม่" : "รหัสผ่าน"}</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>
|
||||
<FieldLabel htmlFor="confirm-password">{hasPassword ? "ยืนยันรหัสผ่านใหม่" : "ยืนยันรหัสผ่าน"}</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>}
|
||||
{success && <Alert role="status"><AlertDescription>{success}</AlertDescription></Alert>}
|
||||
</FieldGroup>
|
||||
</form>
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Button type="submit" form="password-form" disabled={busy}>
|
||||
{busy ? "กำลังเปลี่ยนรหัสผ่าน..." : "เปลี่ยนรหัสผ่าน"}
|
||||
{busy
|
||||
? hasPassword ? "กำลังเปลี่ยนรหัสผ่าน..." : "กำลังเพิ่มรหัสผ่าน..."
|
||||
: hasPassword ? "เปลี่ยนรหัสผ่าน" : "เพิ่มรหัสผ่าน"}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>;
|
||||
|
||||
Reference in New Issue
Block a user