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;
+}