Refactor authentication system: replace NextAuth with custom login/logout/session handling, improve cookie management, and enhance error handling
Build and Push Reader Image / docker (push) Successful in 39s
Build and Push Reader Image / docker (push) Successful in 39s
This commit is contained in:
@@ -1,6 +1,21 @@
|
||||
import NextAuth from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { NextResponse } from "next/server"
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
function disabled() {
|
||||
return NextResponse.json(
|
||||
{
|
||||
detail: "Legacy NextAuth route is disabled. Use /api/auth/login, /api/auth/session, /api/auth/logout.",
|
||||
},
|
||||
{ status: 410 },
|
||||
)
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
return disabled()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { AUTH_COOKIE_MAX_AGE_SECONDS, AUTH_COOKIE_NAME } from "@/lib/auth-cookie"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||
|
||||
type MobileLoginResponse = {
|
||||
accessToken: string
|
||||
expiresIn?: number
|
||||
user: {
|
||||
id: string
|
||||
email?: string | null
|
||||
name?: string | null
|
||||
image?: string | null
|
||||
role?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
const googleIdToken = String(body?.googleIdToken || "").trim()
|
||||
|
||||
if (!googleIdToken) {
|
||||
return NextResponse.json({ detail: "googleIdToken is required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const upstream = await fetch(`${readerApiOrigin}/api/auth/mobile-login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ googleIdToken }),
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(10000),
|
||||
})
|
||||
|
||||
if (!upstream.ok) {
|
||||
const message = await upstream.text()
|
||||
return NextResponse.json({ detail: message || "Authentication failed" }, { status: upstream.status })
|
||||
}
|
||||
|
||||
const data = (await upstream.json()) as MobileLoginResponse
|
||||
|
||||
const response = NextResponse.json(
|
||||
{
|
||||
user: {
|
||||
id: data.user.id,
|
||||
email: data.user.email || null,
|
||||
name: data.user.name || null,
|
||||
image: data.user.image || null,
|
||||
role: data.user.role || "USER",
|
||||
},
|
||||
},
|
||||
{ status: 200 },
|
||||
)
|
||||
|
||||
response.cookies.set(AUTH_COOKIE_NAME, data.accessToken, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: data.expiresIn || AUTH_COOKIE_MAX_AGE_SECONDS,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (error) {
|
||||
console.error("/api/auth/login failed", error)
|
||||
return NextResponse.json({ detail: "Internal Server Error" }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextResponse } from "next/server"
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export async function POST() {
|
||||
const response = NextResponse.json({ success: true }, { status: 200 })
|
||||
response.cookies.set(AUTH_COOKIE_NAME, "", {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
})
|
||||
return response
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || ""
|
||||
if (!accessToken) {
|
||||
return NextResponse.json({ user: null }, { status: 200 })
|
||||
}
|
||||
|
||||
try {
|
||||
const upstream = await fetch(`${readerApiOrigin}/api/auth/session`, {
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!upstream.ok) {
|
||||
return NextResponse.json({ user: null }, { status: 200 })
|
||||
}
|
||||
|
||||
const data = await upstream.json()
|
||||
const user = data?.user || null
|
||||
return NextResponse.json({ user }, { status: 200 })
|
||||
} catch (error) {
|
||||
console.error("/api/auth/session failed", error)
|
||||
return NextResponse.json({ user: null }, { status: 200 })
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getToken } from "next-auth/jwt"
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -7,8 +7,7 @@ export const dynamic = "force-dynamic"
|
||||
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||
|
||||
async function proxyToReaderApi(req: NextRequest, path: string[]) {
|
||||
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })
|
||||
const accessToken = typeof (token as any)?.accessToken === "string" ? (token as any).accessToken : null
|
||||
const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || null
|
||||
|
||||
const url = new URL(req.url)
|
||||
const query = url.search || ""
|
||||
@@ -16,6 +15,7 @@ async function proxyToReaderApi(req: NextRequest, path: string[]) {
|
||||
|
||||
const headers = new Headers(req.headers)
|
||||
headers.delete("host")
|
||||
headers.delete("cookie")
|
||||
if (accessToken) {
|
||||
headers.set("authorization", `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { getToken } from "next-auth/jwt"
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie"
|
||||
|
||||
export const runtime = "nodejs"
|
||||
export const dynamic = "force-dynamic"
|
||||
@@ -7,8 +7,7 @@ export const dynamic = "force-dynamic"
|
||||
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||
|
||||
async function proxyToReaderApi(req: NextRequest, path: string[]) {
|
||||
const token = await getToken({ req, secret: process.env.NEXTAUTH_SECRET })
|
||||
const accessToken = typeof (token as any)?.accessToken === "string" ? (token as any).accessToken : null
|
||||
const accessToken = req.cookies.get(AUTH_COOKIE_NAME)?.value || null
|
||||
|
||||
const url = new URL(req.url)
|
||||
const query = url.search || ""
|
||||
@@ -16,6 +15,7 @@ async function proxyToReaderApi(req: NextRequest, path: string[]) {
|
||||
|
||||
const headers = new Headers(req.headers)
|
||||
headers.delete("host")
|
||||
headers.delete("cookie")
|
||||
if (accessToken) {
|
||||
headers.set("authorization", `Bearer ${accessToken}`)
|
||||
}
|
||||
|
||||
+18
-5
@@ -1,11 +1,12 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect } from "react"
|
||||
import { useEffect, useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useAuth } from "@/lib/auth-context"
|
||||
import { toast } from "sonner"
|
||||
|
||||
function GoogleIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
@@ -21,6 +22,7 @@ function GoogleIcon({ className }: { className?: string }) {
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const { user, loginWithGoogle } = useAuth()
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
@@ -28,9 +30,19 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [user, router])
|
||||
|
||||
const handleGoogleLogin = () => {
|
||||
loginWithGoogle()
|
||||
router.push("/")
|
||||
const handleGoogleLogin = async () => {
|
||||
if (isSubmitting) return
|
||||
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
await loginWithGoogle()
|
||||
router.push("/")
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Đăng nhập thất bại"
|
||||
toast.error(message)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -52,9 +64,10 @@ export default function LoginPage() {
|
||||
variant="outline"
|
||||
className="flex h-12 w-full items-center justify-center gap-3 text-sm font-medium"
|
||||
onClick={handleGoogleLogin}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<GoogleIcon className="h-5 w-5" />
|
||||
{"Dang nhap bang Google"}
|
||||
{isSubmitting ? "Dang xu ly..." : "Dang nhap bang Google"}
|
||||
</Button>
|
||||
|
||||
<div className="mt-4 text-center text-xs text-muted-foreground">
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { toast } from "sonner"
|
||||
|
||||
// Trang này được đặt dưới /mod layout đã kiểm soát quyền ở server side.
|
||||
|
||||
type NovelRow = {
|
||||
id: string
|
||||
title: string
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { EditorClient } from "./editor-client"
|
||||
|
||||
export default async function ModEditChapterPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
const resolvedParams = await params
|
||||
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { ChapterClient } from "./chapter-client"
|
||||
|
||||
export default async function ModChuongPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
return <ChapterClient />
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { RecommendationClient } from "./recommendation-client"
|
||||
|
||||
export default async function ModRecommendationPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
return <RecommendationClient />
|
||||
}
|
||||
|
||||
+2
-9
@@ -1,6 +1,4 @@
|
||||
import { redirect } from "next/navigation"
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { CollapsibleSidebar } from "./collapsible-sidebar"
|
||||
|
||||
export default async function ModLayout({
|
||||
@@ -8,12 +6,7 @@ export default async function ModLayout({
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
// Kiểm tra quyền
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/") // Không đủ quyền, đưa về trang chủ
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
return (
|
||||
<div className="flex min-h-[calc(100vh-3.5rem)] bg-muted/20">
|
||||
|
||||
+6
-6
@@ -1,13 +1,13 @@
|
||||
import { getServerSession } from "next-auth"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import Link from "next/link"
|
||||
import { Sparkles } from "lucide-react"
|
||||
import { cookies } from "next/headers"
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
|
||||
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||
|
||||
export default async function ModDashboardPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
const sessionUser = await requireModSessionUser()
|
||||
|
||||
let novelCount = 0
|
||||
let totalViews = 0
|
||||
@@ -15,10 +15,10 @@ export default async function ModDashboardPage() {
|
||||
let seriesCount = 0
|
||||
|
||||
try {
|
||||
const cookieHeader = (await cookies()).toString()
|
||||
const accessToken = (await cookies()).get(AUTH_COOKIE_NAME)?.value || ""
|
||||
const res = await fetch(`${readerApiOrigin}/api/mod/overview`, {
|
||||
cache: "no-store",
|
||||
headers: cookieHeader ? { cookie: cookieHeader } : undefined,
|
||||
headers: accessToken ? { authorization: `Bearer ${accessToken}` } : undefined,
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json()
|
||||
@@ -33,7 +33,7 @@ export default async function ModDashboardPage() {
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-4">Xin chào, {session?.user.name}</h1>
|
||||
<h1 className="text-2xl font-bold mb-4">Xin chào, {sessionUser?.name || "Moderator"}</h1>
|
||||
<p className="text-muted-foreground mb-6">
|
||||
Chào mừng bạn đến với trang quản trị dành cho Moderator.
|
||||
</p>
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { SeriesClient } from "./series-client"
|
||||
|
||||
export default async function ModSeriesPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
return <SeriesClient />
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { GenreClient } from "./genre-client"
|
||||
|
||||
export default async function ModTheLoaiPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
return <GenreClient />
|
||||
}
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { MissingFieldsClient } from "./missing-fields-client"
|
||||
|
||||
export default async function ModMissingFieldsPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
return <MissingFieldsClient />
|
||||
}
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import { getServerSession } from "next-auth/next"
|
||||
import { authOptions } from "@/lib/auth"
|
||||
import { redirect } from "next/navigation"
|
||||
import { requireModSessionUser } from "@/lib/server-auth"
|
||||
import { NovelClient } from "./novel-client"
|
||||
|
||||
export default async function ModTruyenPage() {
|
||||
const session = await getServerSession(authOptions)
|
||||
if (!session || (session.user.role !== "MOD" && session.user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
await requireModSessionUser()
|
||||
|
||||
return <NovelClient />
|
||||
}
|
||||
|
||||
@@ -206,7 +206,7 @@ export function Header() {
|
||||
</Link>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem onClick={logout} className="flex items-center gap-2 text-destructive">
|
||||
<DropdownMenuItem onClick={() => void logout()} className="flex items-center gap-2 text-destructive">
|
||||
<LogOut className="h-4 w-4" />
|
||||
Đăng Xuất
|
||||
</DropdownMenuItem>
|
||||
@@ -304,7 +304,7 @@ export function Header() {
|
||||
<p className="text-xs text-primary">Loại tài khoản: {roleLabel(user.role)}</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { logout(); setOpen(false) }}>
|
||||
<Button variant="ghost" size="sm" className="w-full justify-start text-destructive" onClick={() => { void logout(); setOpen(false) }}>
|
||||
<LogOut className="mr-2 h-4 w-4" />
|
||||
Đăng Xuất
|
||||
</Button>
|
||||
|
||||
+1
-2
@@ -13,8 +13,7 @@ services:
|
||||
- NEXTAUTH_SECRET=your-super-secret-key
|
||||
# Sửa thành domain name thực tế bạn đang truy cập
|
||||
- NEXTAUTH_URL=http://master-02:3003
|
||||
# Ép IPv4-first để tránh IPv6 Happy Eyeballs timeout khi gọi Google OAuth (OAUTH_CALLBACK_ERROR)
|
||||
- NODE_OPTIONS=--dns-result-order=ipv4first
|
||||
- NEXT_PUBLIC_GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com
|
||||
- GOOGLE_CLIENT_ID=752734667309-khhufui27coorhmk8gh15epbpbeerg25.apps.googleusercontent.com
|
||||
- GOOGLE_CLIENT_SECRET=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
|
||||
volumes:
|
||||
|
||||
+3
-26
@@ -1,28 +1,5 @@
|
||||
/**
|
||||
* Next.js Instrumentation Hook — chạy một lần khi server khởi động.
|
||||
*
|
||||
* Mục đích:
|
||||
* 1. Ép IPv4-first để tránh Happy Eyeballs timeout khi gọi Google OAuth
|
||||
* (openid-client bên trong NextAuth gọi oauth2.googleapis.com).
|
||||
* 2. Tắt keep-alive trên global https agent để tránh stale connection:
|
||||
* Sau lần đăng nhập đầu, connection pool giữ lại TCP socket tới Google.
|
||||
* NAT/firewall của Docker drop socket này sau vài phút (silently).
|
||||
* Khi đăng nhập lần 2, openid-client cố reuse socket đã chết → treo 3500ms.
|
||||
* keepAlive: false buộc mở connection mới mỗi request, không reuse pool cũ.
|
||||
* Next.js Instrumentation Hook.
|
||||
* Hiện tại không áp dụng side effects để tránh ảnh hưởng runtime khởi động.
|
||||
*/
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { setDefaultResultOrder } = await import("dns")
|
||||
setDefaultResultOrder("ipv4first")
|
||||
|
||||
const https = await import("https")
|
||||
https.globalAgent = new https.Agent({
|
||||
keepAlive: false,
|
||||
})
|
||||
|
||||
const http = await import("http")
|
||||
http.globalAgent = new http.Agent({
|
||||
keepAlive: false,
|
||||
})
|
||||
}
|
||||
}
|
||||
export async function register() {}
|
||||
|
||||
+122
-9
@@ -1,21 +1,104 @@
|
||||
"use client"
|
||||
|
||||
import { SessionProvider, useSession, signIn, signOut } from "next-auth/react"
|
||||
import { useMemo, type ReactNode } from "react"
|
||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react"
|
||||
import type { User } from "./types"
|
||||
|
||||
let googleScriptPromise: Promise<void> | null = null
|
||||
|
||||
function ensureGoogleIdentityScript() {
|
||||
if (typeof window === "undefined") return Promise.resolve()
|
||||
if ((window as any).google?.accounts?.id) return Promise.resolve()
|
||||
if (googleScriptPromise) return googleScriptPromise
|
||||
|
||||
googleScriptPromise = new Promise((resolve, reject) => {
|
||||
const existing = document.querySelector('script[src="https://accounts.google.com/gsi/client"]')
|
||||
if (existing) {
|
||||
existing.addEventListener("load", () => resolve(), { once: true })
|
||||
existing.addEventListener("error", () => reject(new Error("Failed to load Google script")), { once: true })
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement("script")
|
||||
script.src = "https://accounts.google.com/gsi/client"
|
||||
script.async = true
|
||||
script.defer = true
|
||||
script.onload = () => resolve()
|
||||
script.onerror = () => reject(new Error("Failed to load Google script"))
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
|
||||
return googleScriptPromise
|
||||
}
|
||||
|
||||
async function requestGoogleIdToken(clientId: string): Promise<string> {
|
||||
await ensureGoogleIdentityScript()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const googleApi = (window as any).google?.accounts?.id
|
||||
if (!googleApi) {
|
||||
reject(new Error("Google Identity API is unavailable"))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
|
||||
googleApi.initialize({
|
||||
client_id: clientId,
|
||||
callback: (response: { credential?: string }) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
const credential = (response?.credential || "").trim()
|
||||
if (!credential) {
|
||||
reject(new Error("Google did not return ID token"))
|
||||
return
|
||||
}
|
||||
resolve(credential)
|
||||
},
|
||||
auto_select: false,
|
||||
cancel_on_tap_outside: true,
|
||||
})
|
||||
|
||||
googleApi.prompt((notification: any) => {
|
||||
if (settled) return
|
||||
|
||||
if (notification?.isNotDisplayed?.() || notification?.isSkippedMoment?.()) {
|
||||
settled = true
|
||||
reject(new Error("Google sign-in prompt was closed or not displayed"))
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
return <SessionProvider>{children}</SessionProvider>
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
// Giữ nguyên custom hook `useAuth` để tương thích ngược với UI components hiện tại
|
||||
export function useAuth() {
|
||||
const { data: session, status } = useSession()
|
||||
const [sessionUser, setSessionUser] = useState<any>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
const isLoading = status === "loading"
|
||||
const fetchSession = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const res = await fetch("/api/auth/session", { cache: "no-store" })
|
||||
if (!res.ok) {
|
||||
setSessionUser(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Chuyển đổi session user thành format User của project
|
||||
const sessionUser = session?.user
|
||||
const data = await res.json()
|
||||
setSessionUser(data?.user || null)
|
||||
} catch {
|
||||
setSessionUser(null)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchSession()
|
||||
}, [fetchSession])
|
||||
|
||||
const user: User | null = useMemo(() => {
|
||||
if (!sessionUser) return null
|
||||
@@ -30,8 +113,38 @@ export function useAuth() {
|
||||
}
|
||||
}, [sessionUser])
|
||||
|
||||
const loginWithGoogle = () => signIn("google", { callbackUrl: "/" })
|
||||
const logout = () => signOut({ callbackUrl: "/" })
|
||||
const loginWithGoogle = async () => {
|
||||
const clientId = (process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || "").trim()
|
||||
if (!clientId) {
|
||||
throw new Error("NEXT_PUBLIC_GOOGLE_CLIENT_ID is not configured")
|
||||
}
|
||||
|
||||
const googleIdToken = await requestGoogleIdToken(clientId)
|
||||
|
||||
const result = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ googleIdToken }),
|
||||
})
|
||||
|
||||
if (!result.ok) {
|
||||
const errorText = await result.text()
|
||||
throw new Error(errorText || "Đăng nhập thất bại")
|
||||
}
|
||||
|
||||
const payload = await result.json()
|
||||
setSessionUser(payload?.user || null)
|
||||
|
||||
return payload
|
||||
}
|
||||
|
||||
const logout = async () => {
|
||||
try {
|
||||
await fetch("/api/auth/logout", { method: "POST" })
|
||||
} finally {
|
||||
setSessionUser(null)
|
||||
}
|
||||
}
|
||||
|
||||
return { user, isLoading, loginWithGoogle, logout }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
export const AUTH_COOKIE_NAME = "reader_access_token"
|
||||
export const AUTH_COOKIE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
import { NextAuthOptions } from "next-auth"
|
||||
import GoogleProvider from "next-auth/providers/google"
|
||||
|
||||
const readerApiOrigin = process.env.READER_API_ORIGIN?.replace(/\/+$/, "")
|
||||
const googleClientId = process.env.GOOGLE_CLIENT_ID
|
||||
const googleClientSecret = process.env.GOOGLE_CLIENT_SECRET
|
||||
|
||||
type MobileLoginResponse = {
|
||||
accessToken: string
|
||||
user: {
|
||||
id: string
|
||||
email?: string | null
|
||||
name?: string | null
|
||||
image?: string | null
|
||||
role?: string | null
|
||||
}
|
||||
}
|
||||
|
||||
export const authOptions: NextAuthOptions = {
|
||||
providers: [
|
||||
GoogleProvider({
|
||||
clientId: googleClientId || "",
|
||||
clientSecret: googleClientSecret || "",
|
||||
httpOptions: {
|
||||
timeout: 10000,
|
||||
},
|
||||
}),
|
||||
],
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
},
|
||||
callbacks: {
|
||||
async jwt({ token, account }) {
|
||||
if (account?.provider === "google" && account.id_token) {
|
||||
if (!readerApiOrigin) {
|
||||
console.warn("READER_API_ORIGIN is not configured, skipping reader-api sync after Google login")
|
||||
return token
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${readerApiOrigin}/api/auth/mobile-login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ googleIdToken: account.id_token }),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error("reader-api sync failed", {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
const data = (await response.json()) as MobileLoginResponse
|
||||
token.sub = data.user.id
|
||||
;(token as any).id = data.user.id
|
||||
;(token as any).role = data.user.role || "USER"
|
||||
;(token as any).name = data.user.name || token.name || null
|
||||
;(token as any).email = data.user.email || token.email || null
|
||||
;(token as any).picture = data.user.image || (token as any).picture || null
|
||||
;(token as any).accessToken = data.accessToken
|
||||
} catch (error) {
|
||||
console.error("Failed to sync Google login with reader-api", error)
|
||||
}
|
||||
}
|
||||
|
||||
return token
|
||||
},
|
||||
async session({ session, token }) {
|
||||
if (session.user) {
|
||||
session.user.id = String((token as any).id || token.sub || "")
|
||||
session.user.role = String((token as any).role || "USER")
|
||||
session.user.name = ((token as any).name ?? token.name ?? session.user.name ?? null) as string | null
|
||||
session.user.email = ((token as any).email ?? token.email ?? session.user.email ?? null) as string | null
|
||||
session.user.image = ((token as any).picture ?? (token as any).image ?? session.user.image ?? null) as string | null
|
||||
;(session as any).accessToken = (token as any).accessToken || null
|
||||
}
|
||||
return session
|
||||
},
|
||||
},
|
||||
secret: process.env.NEXTAUTH_SECRET,
|
||||
debug: process.env.NEXTAUTH_DEBUG === "true",
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { redirect } from "next/navigation"
|
||||
import { AUTH_COOKIE_NAME } from "@/lib/auth-cookie"
|
||||
|
||||
const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000").replace(/\/+$/, "")
|
||||
|
||||
export type ApiSessionUser = {
|
||||
id: string
|
||||
email?: string | null
|
||||
name?: string | null
|
||||
image?: string | null
|
||||
role?: string | null
|
||||
}
|
||||
|
||||
export async function getApiSessionUser(): Promise<ApiSessionUser | null> {
|
||||
const cookieStore = await cookies()
|
||||
const accessToken = cookieStore.get(AUTH_COOKIE_NAME)?.value || ""
|
||||
|
||||
if (!accessToken) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${readerApiOrigin}/api/auth/session`, {
|
||||
method: "GET",
|
||||
headers: { authorization: `Bearer ${accessToken}` },
|
||||
cache: "no-store",
|
||||
signal: AbortSignal.timeout(5000),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return (data?.user || null) as ApiSessionUser | null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export async function requireModSessionUser(): Promise<ApiSessionUser> {
|
||||
const user = await getApiSessionUser()
|
||||
if (!user || (user.role !== "MOD" && user.role !== "ADMIN")) {
|
||||
redirect("/")
|
||||
}
|
||||
|
||||
return user
|
||||
}
|
||||
@@ -3,13 +3,6 @@ const readerApiOrigin = (process.env.READER_API_ORIGIN || "http://localhost:8000
|
||||
|
||||
const nextConfig = {
|
||||
output: "standalone",
|
||||
// Tắt HTTP keep-alive để tránh stale connection tới Google OAuth.
|
||||
// Sau lần đăng nhập đầu, Node.js giữ TCP socket tới oauth2.googleapis.com trong pool.
|
||||
// NAT/firewall của Docker drop socket sau vài phút (silently). Khi login lần 2,
|
||||
// openid-client (bên trong NextAuth) reuse socket đã chết → request treo → OAUTH_CALLBACK_ERROR 3500ms.
|
||||
httpAgentOptions: {
|
||||
keepAlive: false,
|
||||
},
|
||||
typescript: {
|
||||
ignoreBuildErrors: true,
|
||||
},
|
||||
|
||||
Vendored
-28
@@ -1,28 +0,0 @@
|
||||
import NextAuth from "next-auth"
|
||||
import { JWT } from "next-auth/jwt"
|
||||
|
||||
declare module "next-auth" {
|
||||
interface Session {
|
||||
user: {
|
||||
id: string
|
||||
name?: string | null
|
||||
email?: string | null
|
||||
image?: string | null
|
||||
role: string
|
||||
}
|
||||
accessToken?: string | null
|
||||
}
|
||||
|
||||
interface User {
|
||||
role: string
|
||||
}
|
||||
}
|
||||
|
||||
declare module "next-auth/jwt" {
|
||||
interface JWT {
|
||||
id?: string
|
||||
role?: string
|
||||
accessToken?: string | null
|
||||
picture?: string | null
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user