From fdbabecf560601d20188b8f039e3735d9b1adf9a Mon Sep 17 00:00:00 2001 From: gunshiz Date: Mon, 12 Oct 2026 00:51:29 +0700 Subject: [PATCH] feat(auth) : improve Google account handling --- app/api/auth/[...all]/route.ts | 26 ++++++++++++++++++- app/auth/login/page.tsx | 7 +++-- app/page.tsx | 4 +++ components/auth/account-form.tsx | 11 ++++++++ .../auth/account-link-error-redirect.tsx | 18 +++++++++++++ components/auth/account-page.tsx | 19 +++++++++----- components/auth/profile-form.tsx | 6 ++++- 7 files changed, 80 insertions(+), 11 deletions(-) create mode 100644 components/auth/account-link-error-redirect.tsx diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts index aa5a03a..8c8d8f5 100644 --- a/app/api/auth/[...all]/route.ts +++ b/app/api/auth/[...all]/route.ts @@ -1,5 +1,8 @@ import { toNextJsHandler } from "better-auth/next-js"; -import { getAuth } from "@/lib/auth/server"; +import { eq, sql } from "drizzle-orm"; +import { getDb } from "@/db"; +import { accounts } from "@/db/schema"; +import { getAuth, getCustomerSession } from "@/lib/auth/server"; import { errorResponse, HttpError, readJson, requireSameOrigin } from "@/lib/security/http"; const handlers = toNextJsHandler((request) => getAuth().handler(request)); @@ -21,6 +24,27 @@ async function mutate(request: Request) { throw new HttpError(400, "invalid-password-length"); } } + if (path === "/api/auth/unlink-account") { + const accountId = input && typeof input === "object" && "accountId" in input + && typeof input.accountId === "string" ? input.accountId : null; + const session = accountId ? await getCustomerSession() : null; + if (accountId && session) { + const linkedAccounts = await 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 accountExists = linkedAccounts.some((account) => account.id === accountId); + const hasAlternativeSignIn = linkedAccounts.some((account) => account.id !== accountId + && (account.providerId !== "credential" || account.hasPassword)); + if (accountExists && !hasAlternativeSignIn) { + return Response.json({ + code: "LAST_AUTH_METHOD", + message: "กรุณาตั้งรหัสผ่านหรือเชื่อมต่อวิธีเข้าสู่ระบบอื่นก่อนยกเลิกการเชื่อมต่อ Google", + }, { status: 409, headers: { "Cache-Control": "no-store" } }); + } + } + } return await getAuth().handler(request); } catch (cause) { return errorResponse(cause); diff --git a/app/auth/login/page.tsx b/app/auth/login/page.tsx index f138bb3..110945b 100644 --- a/app/auth/login/page.tsx +++ b/app/auth/login/page.tsx @@ -5,7 +5,10 @@ export const instant = false; export default async function LoginPage({ searchParams }: PageProps<"/auth/login">) { await connection(); - const { next, verified, error } = await searchParams; + const { next, verified, error, googleError } = await searchParams; + const accountNotLinkedError = error === "account_not_linked"; + const googleSignInError = googleError === "1" && !accountNotLinkedError; return ; + verificationError={typeof error === "string" && !accountNotLinkedError && !googleSignInError} + accountNotLinkedError={accountNotLinkedError} googleSignInError={googleSignInError} />; } diff --git a/app/page.tsx b/app/page.tsx index 504ec5e..2fc7992 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,6 +1,7 @@ import { connection } from "next/server"; import { Suspense } from "react"; +import { AccountLinkErrorRedirect } from "@/components/auth/account-link-error-redirect"; import { LiveRefresh } from "@/components/live-refresh"; import { GoogleAdsense } from "@/components/public/google-adsense"; import { DirectorySkeleton } from "@/components/public/directory-skeleton"; @@ -20,6 +21,9 @@ export default function HomePage() {
+ + +
diff --git a/components/auth/account-form.tsx b/components/auth/account-form.tsx index eebe76f..2b655fa 100644 --- a/components/auth/account-form.tsx +++ b/components/auth/account-form.tsx @@ -37,6 +37,8 @@ export function AccountForm({ googleEnabled, verified, verificationError, + accountNotLinkedError, + googleSignInError, }: { mode: "login" | "register"; siteKey: string; @@ -44,6 +46,8 @@ export function AccountForm({ googleEnabled: boolean; verified?: boolean; verificationError?: boolean; + accountNotLinkedError?: boolean; + googleSignInError?: boolean; }) { const router = useRouter(); const register = mode === "register"; @@ -120,6 +124,7 @@ export function AccountForm({ const result = await authClient.signIn.social({ provider: "google", callbackURL: nextPath, + errorCallbackURL: `/auth/login?googleError=1${nextPath === "/" ? "" : `&next=${encodeURIComponent(nextPath)}`}`, newUserCallbackURL: `/auth/register?step=profile&next=${encodeURIComponent(nextPath)}`, fetchOptions: { headers: captcha ? { "x-captcha-response": token } : undefined }, }); @@ -228,6 +233,12 @@ export function AccountForm({ {verified && ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ} {verificationError && ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่} + {googleSignInError && + เข้าสู่ระบบด้วย Google ไม่สำเร็จ กรุณาลองอีกครั้ง + } + {accountNotLinkedError && + บัญชี Google นี้ยังไม่ได้เชื่อมกับบัญชี Buzz Guide ของคุณ กรุณาเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน แล้วเชื่อม Google ในหน้าโปรไฟล์ + } {notice && {notice}}
diff --git a/components/auth/account-link-error-redirect.tsx b/components/auth/account-link-error-redirect.tsx new file mode 100644 index 0000000..d9e83b1 --- /dev/null +++ b/components/auth/account-link-error-redirect.tsx @@ -0,0 +1,18 @@ +"use client"; + +import { useEffect } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +export function AccountLinkErrorRedirect() { + const router = useRouter(); + const searchParams = useSearchParams(); + const error = searchParams.get("error"); + + useEffect(() => { + if (error === "account_not_linked") { + router.replace("/auth/login?error=account_not_linked"); + } + }, [error, router]); + + return null; +} diff --git a/components/auth/account-page.tsx b/components/auth/account-page.tsx index 1a9fea9..d5ed10f 100644 --- a/components/auth/account-page.tsx +++ b/components/auth/account-page.tsx @@ -1,7 +1,7 @@ import { redirect } from "next/navigation"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { getDb } from "@/db"; -import { users } from "@/db/schema"; +import { accounts, users } from "@/db/schema"; import { SiteHeader } from "@/components/public/site-header"; import { isAuthorizedAdmin } from "@/lib/auth/authorization"; import { getCustomerSession, isGoogleAuthEnabled } from "@/lib/auth/server"; @@ -9,19 +9,23 @@ 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, setup = false }: { - mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean; setup?: boolean; +export async function AccountPage({ mode, next, verified, verificationError, accountNotLinkedError, googleSignInError, setup = false }: { + mode: "login" | "register"; next: unknown; verified?: boolean; verificationError?: boolean; + accountNotLinkedError?: boolean; googleSignInError?: boolean; setup?: boolean; }) { const nextPath = safeAuthReturnPath(next); const session = await getCustomerSession(); if (session) { 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 }) + const [user] = await getDb().select({ name: users.name, email: users.email, image: users.image, + hasGoogleAccount: sql`exists (select 1 from ${accounts} + where ${accounts.userId} = ${users.id} and ${accounts.providerId} = 'google')` }) .from(users).where(eq(users.id, session.user.id)).limit(1); if (!user) redirect("/auth/login"); return
-
+
; } if (returnPath !== nextPath) redirect(returnPath); @@ -31,6 +35,7 @@ export async function AccountPage({ mode, next, verified, verificationError, set if (process.env.NODE_ENV !== "development" && !siteKey) throw new Error("TURNSTILE_SITE_KEY is required"); return
+ verified={verified} verificationError={verificationError} accountNotLinkedError={accountNotLinkedError} + googleSignInError={googleSignInError} />
; } diff --git a/components/auth/profile-form.tsx b/components/auth/profile-form.tsx index 127d6f2..f8c6c65 100644 --- a/components/auth/profile-form.tsx +++ b/components/auth/profile-form.tsx @@ -24,12 +24,14 @@ export function ProfileForm({ image: initialImage, nextPath, setup = false, + googlePhotoDefault = false, }: { name: string; email: string; image: string | null; nextPath?: string; setup?: boolean; + googlePhotoDefault?: boolean; }) { const [name, setName] = useState(initialName); const [file, setFile] = useState(null); @@ -83,7 +85,9 @@ export function ProfileForm({ {setup &&

- สร้างบัญชีเรียบร้อยแล้ว เพิ่มรูปโปรไฟล์ได้ตามต้องการ หรือกดข้ามเพื่อเริ่มใช้งาน คุณสามารถยืนยันอีเมลภายหลังได้ที่หน้าโปรไฟล์ + {googlePhotoDefault + ? "ใช้รูปโปรไฟล์จาก Google เป็นรูปเริ่มต้นแล้ว หากต้องการเปลี่ยน เลือกรูปใหม่ได้ที่นี่ หรือกดข้ามเพื่อเริ่มใช้งาน" + : "สร้างบัญชีเรียบร้อยแล้ว เพิ่มรูปโปรไฟล์ได้ตามต้องการ หรือกดข้ามเพื่อเริ่มใช้งาน คุณสามารถยืนยันอีเมลภายหลังได้ที่หน้าโปรไฟล์"}

} รูปโปรไฟล์