diff --git a/app/admin/login/page.tsx b/app/admin/login/page.tsx new file mode 100644 index 0000000..05bc6ac --- /dev/null +++ b/app/admin/login/page.tsx @@ -0,0 +1,20 @@ +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { LoginCard } from "@/components/admin/login-card"; +import { SiteHeader } from "@/components/public/site-header"; +import { getAdminSession } from "@/lib/auth/server"; + +export default async function AdminLoginPage() { + await connection(); + if (await getAdminSession()) redirect("/admin"); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/app/api/auth/[...all]/route.ts b/app/api/auth/[...all]/route.ts new file mode 100644 index 0000000..741a91c --- /dev/null +++ b/app/api/auth/[...all]/route.ts @@ -0,0 +1,11 @@ +import { toNextJsHandler } from "better-auth/next-js"; + +import { getAuth } from "@/lib/auth/server"; + +const handlers = toNextJsHandler((request) => getAuth().handler(request)); + +export const GET = handlers.GET; +export const POST = handlers.POST; +export const PATCH = handlers.PATCH; +export const PUT = handlers.PUT; +export const DELETE = handlers.DELETE; diff --git a/components/admin/login-card.tsx b/components/admin/login-card.tsx new file mode 100644 index 0000000..cbed79b --- /dev/null +++ b/components/admin/login-card.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { KeyRoundIcon, LogInIcon } from "lucide-react"; +import { useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Spinner } from "@/components/ui/spinner"; +import { authClient } from "@/lib/auth/client"; + +export function LoginCard() { + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function signIn() { + setLoading(true); + setError(null); + const result = await authClient.signIn.social({ + provider: "google", + callbackURL: "/admin", + errorCallbackURL: "/admin/login?error=oauth", + }); + if (result.error) { + setError("เข้าสู่ระบบไม่สำเร็จ โปรดลองอีกครั้ง"); + setLoading(false); + } + } + + return ( + + +
+
+ เข้าสู่ระบบผู้ดูแล + + ใช้บัญชี Google ที่ได้รับอนุญาตให้จัดการคู่มือ + +
+ + + {error ?

{error}

: null} +
+
+ ); +} diff --git a/lib/auth/authorization.test.ts b/lib/auth/authorization.test.ts new file mode 100644 index 0000000..2f0bdd4 --- /dev/null +++ b/lib/auth/authorization.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; + +import { isAuthorizedAdmin } from "./authorization"; + +const verified = { + id: "admin", + email: "admin@example.com", + emailVerified: true, +}; + +describe("admin authorization", () => { + it("requires an exact verified email match", () => { + expect(isAuthorizedAdmin(verified, "admin@example.com")).toBe(true); + expect(isAuthorizedAdmin(verified, "ADMIN@example.com")).toBe(false); + expect( + isAuthorizedAdmin({ ...verified, emailVerified: false }, verified.email), + ).toBe(false); + }); + + it("denies missing users and missing configuration", () => { + expect(isAuthorizedAdmin(null, verified.email)).toBe(false); + expect(isAuthorizedAdmin(verified, undefined)).toBe(false); + }); +}); diff --git a/lib/auth/authorization.ts b/lib/auth/authorization.ts new file mode 100644 index 0000000..5a301b0 --- /dev/null +++ b/lib/auth/authorization.ts @@ -0,0 +1,19 @@ +export interface SessionUserLike { + id: string; + email: string; + emailVerified: boolean; + name?: string | null; + image?: string | null; +} + +export function isAuthorizedAdmin( + user: SessionUserLike | null | undefined, + adminEmail: string | null | undefined, +): user is SessionUserLike { + return Boolean( + user && + user.emailVerified === true && + adminEmail && + user.email === adminEmail, + ); +} diff --git a/lib/auth/client.ts b/lib/auth/client.ts new file mode 100644 index 0000000..2f75fd4 --- /dev/null +++ b/lib/auth/client.ts @@ -0,0 +1,5 @@ +"use client"; + +import { createAuthClient } from "better-auth/react"; + +export const authClient = createAuthClient(); diff --git a/lib/auth/server.ts b/lib/auth/server.ts new file mode 100644 index 0000000..192bea6 --- /dev/null +++ b/lib/auth/server.ts @@ -0,0 +1,99 @@ +import "server-only"; + +import { betterAuth } from "better-auth"; +import { drizzleAdapter } from "better-auth/adapters/drizzle"; +import { nextCookies } from "better-auth/next-js"; +import { headers } from "next/headers"; + +import { getDb } from "@/db"; +import { + accounts, + sessions, + users, + verifications, +} from "@/db/schema"; + +import { isAuthorizedAdmin, type SessionUserLike } from "./authorization"; + +function required(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`${name} is required for authentication.`); + return value; +} + +function createAuth() { + return betterAuth({ + appName: "Buzz Sheet", + database: drizzleAdapter(getDb(), { + provider: "pg", + schema: { + user: users, + session: sessions, + account: accounts, + verification: verifications, + }, + transaction: true, + }), + baseURL: required("BETTER_AUTH_URL"), + secret: required("BETTER_AUTH_SECRET"), + socialProviders: { + google: { + clientId: required("GOOGLE_CLIENT_ID"), + clientSecret: required("GOOGLE_CLIENT_SECRET"), + }, + }, + plugins: [nextCookies()], + }); +} + +type AuthInstance = ReturnType; +let authInstance: AuthInstance | undefined; + +export function getAuth(): AuthInstance { + if (!authInstance) authInstance = createAuth(); + return authInstance; +} + +export interface AdminSession { + user: SessionUserLike; + session: { id: string }; +} + +export async function getAdminSession(): Promise { + if (process.env.BUZZ_DEMO_MODE === "true") { + return { + user: { + id: "demo-admin", + email: "demo@buzz-sheet.local", + emailVerified: true, + name: "Demo Admin", + }, + session: { id: "demo-session" }, + }; + } + + const session = await getAuth().api.getSession({ headers: await headers() }); + if ( + !session?.session || + !isAuthorizedAdmin(session.user, process.env.ADMIN_EMAIL) + ) { + return null; + } + return { + user: session.user, + session: { id: session.session.id }, + }; +} + +export class AdminAuthorizationError extends Error { + constructor() { + super("Admin authorization required."); + this.name = "AdminAuthorizationError"; + } +} + +export async function requireAdmin(): Promise { + const session = await getAdminSession(); + if (!session) throw new AdminAuthorizationError(); + return session; +}