feat(auth) : improve Google account handling
This commit is contained in:
@@ -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<boolean>`${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);
|
||||
|
||||
@@ -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 <AccountPage mode="login" next={next} verified={verified === "1" && !error}
|
||||
verificationError={typeof error === "string"} />;
|
||||
verificationError={typeof error === "string" && !accountNotLinkedError && !googleSignInError}
|
||||
accountNotLinkedError={accountNotLinkedError} googleSignInError={googleSignInError} />;
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
<div className="flex min-h-svh flex-col bg-background">
|
||||
<GoogleAdsense />
|
||||
<LiveRefresh endpoint="/api/events/directory" />
|
||||
<Suspense fallback={null}>
|
||||
<AccountLinkErrorRedirect />
|
||||
</Suspense>
|
||||
<SiteHeader />
|
||||
<main className="mx-auto flex w-full max-w-7xl flex-col gap-8 px-4 pt-4 pb-10 sm:px-6 sm:pt-6 sm:pb-14">
|
||||
<header className="max-w-3xl">
|
||||
|
||||
@@ -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({
|
||||
<CardContent className="flex flex-col gap-4">
|
||||
{verified && <Alert><AlertDescription>ยืนยันอีเมลแล้ว กรุณาเข้าสู่ระบบ</AlertDescription></Alert>}
|
||||
{verificationError && <Alert variant="destructive"><AlertDescription>ลิงก์ยืนยันไม่ถูกต้องหรือหมดอายุ กรุณาขอลิงก์ใหม่</AlertDescription></Alert>}
|
||||
{googleSignInError && <Alert variant="destructive" role="alert"><AlertDescription>
|
||||
เข้าสู่ระบบด้วย Google ไม่สำเร็จ กรุณาลองอีกครั้ง
|
||||
</AlertDescription></Alert>}
|
||||
{accountNotLinkedError && <Alert variant="destructive" role="alert"><AlertDescription>
|
||||
บัญชี Google นี้ยังไม่ได้เชื่อมกับบัญชี Buzz Guide ของคุณ กรุณาเข้าสู่ระบบด้วยอีเมลและรหัสผ่าน แล้วเชื่อม Google ในหน้าโปรไฟล์
|
||||
</AlertDescription></Alert>}
|
||||
{notice && <Alert role="status"><AlertDescription>{notice}</AlertDescription></Alert>}
|
||||
<form ref={formRef} onSubmit={submit}>
|
||||
<FieldGroup>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<boolean>`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 <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>
|
||||
<div className="w-full max-w-sm"><ProfileForm {...user} nextPath={returnPath} setup
|
||||
googlePhotoDefault={Boolean(user.image && user.hasGoogleAccount)} /></div>
|
||||
</main></div>;
|
||||
}
|
||||
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 <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} googleEnabled={isGoogleAuthEnabled()}
|
||||
verified={verified} verificationError={verificationError} />
|
||||
verified={verified} verificationError={verificationError} accountNotLinkedError={accountNotLinkedError}
|
||||
googleSignInError={googleSignInError} />
|
||||
</main></div>;
|
||||
}
|
||||
|
||||
@@ -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<File | null>(null);
|
||||
@@ -83,7 +85,9 @@ export function ProfileForm({
|
||||
<form id="profile-form" onSubmit={save}>
|
||||
<FieldGroup>
|
||||
{setup && <p className="text-sm text-muted-foreground">
|
||||
สร้างบัญชีเรียบร้อยแล้ว เพิ่มรูปโปรไฟล์ได้ตามต้องการ หรือกดข้ามเพื่อเริ่มใช้งาน คุณสามารถยืนยันอีเมลภายหลังได้ที่หน้าโปรไฟล์
|
||||
{googlePhotoDefault
|
||||
? "ใช้รูปโปรไฟล์จาก Google เป็นรูปเริ่มต้นแล้ว หากต้องการเปลี่ยน เลือกรูปใหม่ได้ที่นี่ หรือกดข้ามเพื่อเริ่มใช้งาน"
|
||||
: "สร้างบัญชีเรียบร้อยแล้ว เพิ่มรูปโปรไฟล์ได้ตามต้องการ หรือกดข้ามเพื่อเริ่มใช้งาน คุณสามารถยืนยันอีเมลภายหลังได้ที่หน้าโปรไฟล์"}
|
||||
</p>}
|
||||
<Field>
|
||||
<FieldLabel htmlFor="profile-image">รูปโปรไฟล์</FieldLabel>
|
||||
|
||||
Reference in New Issue
Block a user