diff --git a/lib/auth-context.tsx b/lib/auth-context.tsx index 7c2f06c..4b756e5 100644 --- a/lib/auth-context.tsx +++ b/lib/auth-context.tsx @@ -1,33 +1,50 @@ "use client" -import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react" +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useState, + type ReactNode, +} from "react" import type { User } from "./types" +// ─── Google Identity Services helpers ─────────────────────────────────────── + let googleScriptPromise: Promise | null = null let googleInitializedClientId = "" let pendingCredentialResolver: ((token: string) => void) | null = null +let pendingCredentialRejecter: ((err: Error) => void) | null = null -function waitForDocumentReady() { +function waitForDocumentReady(): Promise { if (typeof window === "undefined") return Promise.resolve() if (document.readyState !== "loading") return Promise.resolve() - return new Promise((resolve) => { document.addEventListener("DOMContentLoaded", () => resolve(), { once: true }) }) } -async function ensureGoogleIdentityScript() { - if (typeof window === "undefined") return Promise.resolve() - if ((window as any).google?.accounts?.id) return Promise.resolve() +async function ensureGoogleIdentityScript(): Promise { + if (typeof window === "undefined") return + if ((window as any).google?.accounts?.id) return if (googleScriptPromise) return googleScriptPromise await waitForDocumentReady() googleScriptPromise = new Promise((resolve, reject) => { + // Script may already exist (e.g. injected by extension) const existing = document.querySelector('script[src="https://accounts.google.com/gsi/client"]') if (existing) { + if ((window as any).google?.accounts?.id) { + resolve() + return + } existing.addEventListener("load", () => resolve(), { once: true }) - existing.addEventListener("error", () => reject(new Error("Failed to load Google script")), { once: true }) + existing.addEventListener("error", () => reject(new Error("Failed to load Google script")), { + once: true, + }) return } @@ -43,30 +60,29 @@ async function ensureGoogleIdentityScript() { return googleScriptPromise } -async function initializeGoogleIdentity(clientId: string) { +async function initializeGoogleIdentity(clientId: string): Promise { await ensureGoogleIdentityScript() const googleApi = (window as any).google?.accounts?.id - if (!googleApi) { - throw new Error("Google Identity API is unavailable") - } + if (!googleApi) throw new Error("Google Identity API is unavailable") - // Avoid repeated initialize() calls that cause unstable GSI behavior. - if (googleInitializedClientId === clientId) { - return - } + if (googleInitializedClientId === clientId) return + // Re-initialize with new clientId (or first time) googleApi.initialize({ client_id: clientId, callback: (response: { credential?: string }) => { const credential = (response?.credential || "").trim() - if (!credential || !pendingCredentialResolver) { + const resolver = pendingCredentialResolver + const rejecter = pendingCredentialRejecter + pendingCredentialResolver = null + pendingCredentialRejecter = null + + if (!credential) { + rejecter?.(new Error("Google sign-in did not return a credential")) return } - - const resolver = pendingCredentialResolver - pendingCredentialResolver = null - resolver(credential) + resolver?.(credential) }, auto_select: false, cancel_on_tap_outside: true, @@ -79,43 +95,50 @@ async function initializeGoogleIdentity(clientId: string) { async function requestGoogleIdToken(clientId: string): Promise { await initializeGoogleIdentity(clientId) - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const googleApi = (window as any).google?.accounts?.id if (!googleApi) { reject(new Error("Google Identity API is unavailable")) return } - pendingCredentialResolver = resolve + // Reject any previous in-flight request + pendingCredentialRejecter?.(new Error("A new sign-in request was started")) + pendingCredentialResolver = null + pendingCredentialRejecter = null + const timeoutId = window.setTimeout(() => { - if (!pendingCredentialResolver) { - return - } pendingCredentialResolver = null + pendingCredentialRejecter = null reject(new Error("Google sign-in timed out. Please try again.")) }, 15000) - const originalResolver = pendingCredentialResolver pendingCredentialResolver = (token: string) => { window.clearTimeout(timeoutId) - if (!originalResolver) { - reject(new Error("Google sign-in did not complete")) - return - } resolve(token) } + pendingCredentialRejecter = (err: Error) => { + window.clearTimeout(timeoutId) + reject(err) + } googleApi.prompt() }) } -export function AuthProvider({ children }: { children: ReactNode }) { - return <>{children} +// ─── Auth Context ──────────────────────────────────────────────────────────── + +interface AuthContextValue { + user: User | null + isLoading: boolean + loginWithGoogle: () => Promise + logout: () => Promise } -// Giữ nguyên custom hook `useAuth` để tương thích ngược với UI components hiện tại -export function useAuth() { - const [sessionUser, setSessionUser] = useState(null) +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [sessionUser, setSessionUser] = useState | null>(null) const [isLoading, setIsLoading] = useState(true) const [googleClientId, setGoogleClientId] = useState("") @@ -127,9 +150,8 @@ export function useAuth() { setSessionUser(null) return } - const data = await res.json() - setSessionUser(data?.user || null) + setSessionUser((data?.user as Record) || null) } catch { setSessionUser(null) } finally { @@ -138,32 +160,22 @@ export function useAuth() { }, []) useEffect(() => { - fetchSession() + void fetchSession() }, [fetchSession]) useEffect(() => { let active = true - const fetchAuthConfig = async () => { try { const res = await fetch("/api/auth/config", { cache: "no-store" }) - if (!res.ok) { - return - } - + if (!res.ok) return const data = await res.json() - if (active) { - setGoogleClientId(String(data?.googleClientId || "").trim()) - } + if (active) setGoogleClientId(String(data?.googleClientId || "").trim()) } catch { - if (active) { - setGoogleClientId("") - } + if (active) setGoogleClientId("") } } - void fetchAuthConfig() - return () => { active = false } @@ -172,21 +184,19 @@ export function useAuth() { const user: User | null = useMemo(() => { if (!sessionUser) return null return { - id: (sessionUser as any).id || "", - username: (sessionUser as any).name || "Người dùng", - email: (sessionUser as any).email || "", - avatarUrl: (sessionUser as any).image || "", - avatarColor: "bg-blue-500", // Mặc định - role: (sessionUser as any).role || "USER", + id: String(sessionUser.id || ""), + username: String(sessionUser.name || "Người dùng"), + email: String(sessionUser.email || ""), + avatarUrl: String(sessionUser.image || ""), + avatarColor: "bg-blue-500", + role: (sessionUser.role as "USER" | "MOD" | "ADMIN") || "USER", createdAt: new Date().toISOString().split("T")[0], } }, [sessionUser]) - const loginWithGoogle = async () => { + const loginWithGoogle = useCallback(async () => { const clientId = googleClientId.trim() - if (!clientId) { - throw new Error("Google client id is not configured on server runtime") - } + if (!clientId) throw new Error("Google client id is not configured on server runtime") const googleIdToken = await requestGoogleIdToken(clientId) @@ -202,19 +212,29 @@ export function useAuth() { } const payload = await result.json() - setSessionUser(payload?.user || null) - + setSessionUser((payload?.user as Record) || null) return payload - } + }, [googleClientId]) - const logout = async () => { + const logout = useCallback(async () => { try { await fetch("/api/auth/logout", { method: "POST" }) } finally { setSessionUser(null) } - } + }, []) - return { user, isLoading, loginWithGoogle, logout } + const value = useMemo( + () => ({ user, isLoading, loginWithGoogle, logout }), + [user, isLoading, loginWithGoogle, logout], + ) + + return {children} +} + +export function useAuth(): AuthContextValue { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error("useAuth must be used within AuthProvider") + return ctx }