From 659a57fb4aa2ed0b797aae5c71b8b03427eff4b8 Mon Sep 17 00:00:00 2001 From: gunshiz Date: Sat, 29 Aug 2026 07:54:15 +0000 Subject: [PATCH] feat(auth): replace Google login with credentials --- .env.example | 4 +- Plan.md | 8 +- README.md | 26 ++-- app/register/page.tsx | 20 +++ components/admin/login-card.tsx | 114 +++++++++++++---- components/admin/register-card.tsx | 190 +++++++++++++++++++++++++++++ db/schema.ts | 4 +- k8s.md | 2 +- lib/auth/authorization.test.ts | 36 +++++- lib/auth/authorization.ts | 21 +++- lib/auth/server.ts | 43 ++++++- tests/deployment-contract.test.ts | 2 - 12 files changed, 413 insertions(+), 57 deletions(-) create mode 100644 app/register/page.tsx create mode 100644 components/admin/register-card.tsx diff --git a/.env.example b/.env.example index 682c7f3..a8b14c5 100644 --- a/.env.example +++ b/.env.example @@ -6,11 +6,9 @@ BUZZ_DEMO_MODE=false DATABASE_URL=postgresql://buzz_sheet:replace-me@postgres.example.internal:5432/buzz_sheet?sslmode=require DATABASE_POOL_SIZE=10 -# Better Auth + Google OAuth +# Better Auth email/password administrator login BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes -GOOGLE_CLIENT_ID=replace-with-google-client-id -GOOGLE_CLIENT_SECRET=replace-with-google-client-secret ADMIN_EMAIL=admin@example.com # Redis remote cache, event transport, and outbox worker diff --git a/Plan.md b/Plan.md index 5c4d2a4..a50d2d6 100644 --- a/Plan.md +++ b/Plan.md @@ -96,7 +96,7 @@ The admin uses a visual Notion-style block editor with forms, cards, drag-and-dr - Cache validated public page snapshots by their page and data-source version vector. Use an external Redis-backed Next.js remote cache and tag handler so both application replicas share cache state and invalidations. Keep admin routes dynamic. - Write cache invalidation and SSE work to a transactional outbox with the content mutation. A retrying worker invalidates affected page, directory, and data-source tags only after the database commit, then publishes typed Redis events. - Provide Redis-backed, invalidation-only SSE streams for public pages, the character directory, and authenticated admin sessions. Events carry only opaque IDs and versions; clients refetch authoritative state on connection, reconnection, or notification. Send 90-second heartbeats, close streams after 30 minutes so clients reconnect, and disable Traefik response buffering. SSE is never a source of correctness and never carries page content. -- Authenticate through Google using Better Auth. Permit admin access only when the verified Google email equals `ADMIN_EMAIL`; repeat authorization checks in every mutation, media, and administrative endpoint. +- Authenticate with Better Auth email and password. Permit registration and admin access only when the credential account email exactly equals `ADMIN_EMAIL`; use a temporary `/register` bootstrap page and repeat authorization checks in every mutation, media, and administrative endpoint. - Upload PNG, JPEG, WebP, and GIF assets up to 20 MB through short-lived presigned requests. Serve them through same-origin `/media/[id]` responses. Allow public access only while an asset is referenced by a currently visible snapshot; otherwise require admin authentication. Retain objects while referenced by either current content or retained revisions. - Applying a template clones independent pages, blocks, and data sources. Later template edits affect only future applications. - Do not provide custom HTML, JavaScript, TypeScript, React, CSS, code blocks that execute, external scripts, arbitrary npm packages, or network-capable extensions. @@ -135,8 +135,8 @@ flowchart LR - Implement internally in four gates: 1. Database model, immutable data-source versions, revisions, transactional outbox, formula engine, and workbook-derived fixtures. 2. Public renderer, templates, and responsive shadcn admin editor. - 3. Google authentication, reference-aware S3 media handling, Redis-backed caching and SSE, and conflict recovery. - 4. Production builds, browser tests, Docker, migrations, Kubernetes, and CI. + 3. Better Auth email/password authentication, reference-aware S3 media handling, Redis-backed caching and SSE, and conflict recovery. + 4. Production builds, HTTP and integration tests, Docker, migrations, Kubernetes, and CI. - Release to production once all four gates pass. - During the public/editor gate, initialize shadcn with the `base-nova` preset before adding components. Add only the official components required by the implemented surface, and review generated component source and Base UI composition after each addition. - Implement and commit each completed feature separately. Do not accumulate the project into one large commit. @@ -167,7 +167,7 @@ flowchart LR - Verify those 379 formulas with deterministic decimal results, then separately test precedence, subtraction, multiplication, percentages, unary signs, blank and missing references, cycles, dependency-propagated failures, rounding, and division by zero. - Test block validation, schema migration, unknown-block fallback, templates, custom slugs, redirects, public notes, visibility, ordering, and revision restore. - Test autosave debounce, serialization, transient retries, concurrent conflicts, checkpoint coalescing, named revisions, 30-day expiry, global data-source dependency updates, historical version pinning, “use latest,” and media retention. -- Test Google admin restrictions and authorization on every write/media endpoint. +- Test email/password registration restrictions and authorization on every write/media endpoint. - Test upload limits, file validation, failed upload recovery, and same-origin media delivery. - Test cached public snapshots and immediate dependency-aware invalidation following accepted autosaves. - Test SSE authorization, public-event privacy, heartbeat, reconnection and authoritative refetch, duplicate or missed notifications, and directory/page/admin topics. diff --git a/README.md b/README.md index 64f9ab8..8c03cc0 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Production target: `https://sheet.sudloh.com` - Restricted decimal formula engine with named references, dependency ordering, cycle detection, typed failures, and presentation-only rounding. - Read-only comparison charts with visible values and accessible table fallbacks. -- Google authentication restricted to one verified `ADMIN_EMAIL`. +- Better Auth email/password login restricted to the configured `ADMIN_EMAIL`. - Private S3-compatible media uploads with reference-aware same-origin delivery. - PostgreSQL transactions, immutable data-source versions, revisions, and a retryable outbox. @@ -37,7 +37,8 @@ formula fixtures. Their guide text and media are not imported or published. | `/` | Searchable public character directory | | `/[character]` | Redirect to the first visible page | | `/[character]/[page]` | Render a public guide page | -| `/admin/login` | Google administrator sign-in | +| `/admin/login` | Administrator email/password sign-in | +| `/register` | Temporary administrator account bootstrap | | `/admin` | Character and page overview | | `/admin/[character]/[page]` | Visual page editor | | `/admin/templates` | Apply reusable guide templates | @@ -56,7 +57,6 @@ Requirements: - PostgreSQL - Redis - Private S3-compatible object storage -- Google OAuth credentials Install dependencies and create your local environment file: @@ -66,16 +66,11 @@ cp .env.example .env ``` `.env.example` contains placeholders only. Configure `.env` yourself; it is -ignored by Git and must never be committed. For Google OAuth, register this -authorized redirect URI: - -```text -http://localhost:3000/api/auth/callback/google -``` - -Use the production origin in place of `http://localhost:3000` for the deployed -OAuth client. `BETTER_AUTH_URL` must exactly match that origin, and access is -granted only when Google reports a verified email equal to `ADMIN_EMAIL`. +ignored by Git and must never be committed. `BETTER_AUTH_URL` must exactly +match the application origin. Visit `/register` once to create the credential +account using the exact `ADMIN_EMAIL`, then use `/admin/login` for later access. +The registration endpoint rejects every other email; remove the temporary page +after the administrator account has been created. Prepare the database and start the application: @@ -90,7 +85,8 @@ reference workbook content. ### Fixture-only demo -The UI can be inspected without PostgreSQL, Redis, S3, or Google by running: +The UI can be inspected without PostgreSQL, Redis, S3, or authentication +credentials by running: ```bash BUZZ_DEMO_MODE=true bun run dev @@ -210,8 +206,6 @@ Before the first rollout, a cluster administrator must provision a ```text DATABASE_URL BETTER_AUTH_SECRET -GOOGLE_CLIENT_ID -GOOGLE_CLIENT_SECRET ADMIN_EMAIL REDIS_URL S3_ENDPOINT diff --git a/app/register/page.tsx b/app/register/page.tsx new file mode 100644 index 0000000..07dfff4 --- /dev/null +++ b/app/register/page.tsx @@ -0,0 +1,20 @@ +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { RegisterCard } from "@/components/admin/register-card"; +import { SiteHeader } from "@/components/public/site-header"; +import { getAdminSession } from "@/lib/auth/server"; + +export default async function RegisterPage() { + await connection(); + if (await getAdminSession()) redirect("/admin"); + + return ( +
+ +
+ +
+
+ ); +} diff --git a/components/admin/login-card.tsx b/components/admin/login-card.tsx index cbed79b..4c2f8a7 100644 --- a/components/admin/login-card.tsx +++ b/components/admin/login-card.tsx @@ -1,33 +1,62 @@ "use client"; -import { KeyRoundIcon, LogInIcon } from "lucide-react"; -import { useState } from "react"; +import { CircleAlertIcon, LogInIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; -import { Button } from "@/components/ui/button"; +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; +import { Button, buttonVariants } from "@/components/ui/button"; import { Card, CardContent, CardDescription, + CardFooter, CardHeader, CardTitle, } from "@/components/ui/card"; +import { Field, FieldGroup, FieldLabel } from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; import { Spinner } from "@/components/ui/spinner"; import { authClient } from "@/lib/auth/client"; export function LoginCard() { + const router = useRouter(); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - async function signIn() { + async function signIn(event: FormEvent) { + event.preventDefault(); setLoading(true); setError(null); - const result = await authClient.signIn.social({ - provider: "google", - callbackURL: "/admin", - errorCallbackURL: "/admin/login?error=oauth", - }); - if (result.error) { - setError("เข้าสู่ระบบไม่สำเร็จ โปรดลองอีกครั้ง"); + + const formData = new FormData(event.currentTarget); + const email = String(formData.get("email") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + + try { + const result = await authClient.signIn.email({ + email, + password, + rememberMe: true, + }); + if (result.error) { + setError( + result.error.code === "INVALID_EMAIL_OR_PASSWORD" + ? "อีเมลหรือรหัสผ่านไม่ถูกต้อง" + : "เข้าสู่ระบบไม่สำเร็จ โปรดลองอีกครั้ง", + ); + return; + } + router.push("/admin"); + router.refresh(); + } catch { + setError("เชื่อมต่อระบบเข้าสู่ระบบไม่ได้ โปรดลองอีกครั้ง"); + } finally { setLoading(false); } } @@ -35,21 +64,64 @@ export function LoginCard() { return ( -
-
เข้าสู่ระบบผู้ดูแล - ใช้บัญชี Google ที่ได้รับอนุญาตให้จัดการคู่มือ + ใช้อีเมลและรหัสผ่านของบัญชีผู้ดูแล Better Auth
- - - {error ?

{error}

: null} + +
+ + + อีเมล + + + + รหัสผ่าน + + + {error ? ( + + + ) : null} + + +
+ + + สร้างบัญชีผู้ดูแลชั่วคราว + +
); } diff --git a/components/admin/register-card.tsx b/components/admin/register-card.tsx new file mode 100644 index 0000000..83d1481 --- /dev/null +++ b/components/admin/register-card.tsx @@ -0,0 +1,190 @@ +"use client"; + +import { CircleAlertIcon, UserPlusIcon } from "lucide-react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { type FormEvent, useState } from "react"; + +import { + Alert, + AlertDescription, + AlertTitle, +} from "@/components/ui/alert"; +import { Button, buttonVariants } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardFooter, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { + Field, + FieldDescription, + FieldGroup, + FieldLabel, +} from "@/components/ui/field"; +import { Input } from "@/components/ui/input"; +import { Spinner } from "@/components/ui/spinner"; +import { authClient } from "@/lib/auth/client"; + +function registrationError(code: string | undefined): string { + switch (code) { + case "ADMIN_REGISTRATION_FORBIDDEN": + return "อีเมลนี้ไม่ได้รับอนุญาตให้สร้างบัญชีผู้ดูแล"; + case "USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL": + return "บัญชีนี้มีอยู่แล้ว โปรดกลับไปเข้าสู่ระบบ"; + case "PASSWORD_TOO_SHORT": + return "รหัสผ่านต้องมีอย่างน้อย 8 ตัวอักษร"; + case "PASSWORD_TOO_LONG": + return "รหัสผ่านต้องไม่เกิน 128 ตัวอักษร"; + default: + return "สร้างบัญชีไม่สำเร็จ โปรดตรวจสอบข้อมูลแล้วลองอีกครั้ง"; + } +} + +export function RegisterCard() { + const router = useRouter(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + async function register(event: FormEvent) { + event.preventDefault(); + setError(null); + + const formData = new FormData(event.currentTarget); + const name = String(formData.get("name") ?? "").trim(); + const email = String(formData.get("email") ?? "").trim(); + const password = String(formData.get("password") ?? ""); + const confirmation = String(formData.get("passwordConfirmation") ?? ""); + + if (!name) { + setError("กรุณากรอกชื่อผู้ดูแล"); + return; + } + if (password !== confirmation) { + setError("รหัสผ่านทั้งสองช่องไม่ตรงกัน"); + return; + } + + setLoading(true); + try { + const result = await authClient.signUp.email({ + name, + email, + password, + }); + if (result.error) { + setError(registrationError(result.error.code)); + return; + } + router.push("/admin"); + router.refresh(); + } catch { + setError("เชื่อมต่อระบบลงทะเบียนไม่ได้ โปรดลองอีกครั้ง"); + } finally { + setLoading(false); + } + } + + return ( + + + สร้างบัญชีผู้ดูแล + + ลงทะเบียนด้วยอีเมลและรหัสผ่านสำหรับ Better Auth + + + +
+ + + + + ชื่อผู้ดูแล + + + + อีเมล + + + + รหัสผ่าน + + อย่างน้อย 8 ตัวอักษร + + + + ยืนยันรหัสผ่าน + + + + {error ? ( + + + ) : null} + + +
+
+ + + กลับไปเข้าสู่ระบบ + + +
+ ); +} diff --git a/db/schema.ts b/db/schema.ts index 4502385..c4c7d0f 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -24,8 +24,8 @@ const timestamps = { .notNull(), }; -// Better Auth core tables. Application authorization still compares the -// verified session email with ADMIN_EMAIL at every protected boundary. +// Better Auth core tables. Credential registration is restricted to +// ADMIN_EMAIL, which is rechecked at every protected application boundary. export const users = pgTable( "user", { diff --git a/k8s.md b/k8s.md index 6440868..1461acc 100644 --- a/k8s.md +++ b/k8s.md @@ -69,4 +69,4 @@ and `redis` set to `"ok"`. it requires building and deploying a new image; restarting the existing image is not sufficient. - If `BETTER_AUTH_URL` or the public hostname changes, update the Kubernetes - ingress, Cloudflare/DNS, and the Google OAuth callback URL as well. + ingress and Cloudflare/DNS configuration as well. diff --git a/lib/auth/authorization.test.ts b/lib/auth/authorization.test.ts index 2f0bdd4..61f975d 100644 --- a/lib/auth/authorization.test.ts +++ b/lib/auth/authorization.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from "vitest"; -import { isAuthorizedAdmin } from "./authorization"; +import { + isAllowedAdminRegistration, + isAuthorizedAdmin, + isConfiguredAdminEmail, +} from "./authorization"; const verified = { id: "admin", @@ -9,6 +13,36 @@ const verified = { }; describe("admin authorization", () => { + it("recognizes only the exact configured administrator email", () => { + expect(isConfiguredAdminEmail(verified.email, "admin@example.com")).toBe( + true, + ); + expect(isConfiguredAdminEmail(verified.email, "ADMIN@example.com")).toBe( + false, + ); + expect(isConfiguredAdminEmail(verified.email, undefined)).toBe(false); + }); + + it("allows only credential registration for the configured administrator", () => { + expect( + isAllowedAdminRegistration( + verified.email, + "email-password", + verified.email, + ), + ).toBe(true); + expect( + isAllowedAdminRegistration( + "other@example.com", + "email-password", + verified.email, + ), + ).toBe(false); + expect( + isAllowedAdminRegistration(verified.email, "oauth", verified.email), + ).toBe(false); + }); + it("requires an exact verified email match", () => { expect(isAuthorizedAdmin(verified, "admin@example.com")).toBe(true); expect(isAuthorizedAdmin(verified, "ADMIN@example.com")).toBe(false); diff --git a/lib/auth/authorization.ts b/lib/auth/authorization.ts index 5a301b0..cd11f56 100644 --- a/lib/auth/authorization.ts +++ b/lib/auth/authorization.ts @@ -6,6 +6,24 @@ export interface SessionUserLike { image?: string | null; } +export function isConfiguredAdminEmail( + email: string | null | undefined, + adminEmail: string | null | undefined, +): boolean { + return Boolean(email && adminEmail && email === adminEmail); +} + +export function isAllowedAdminRegistration( + email: string | null | undefined, + authMethod: string, + adminEmail: string | null | undefined, +): boolean { + return ( + authMethod === "email-password" && + isConfiguredAdminEmail(email, adminEmail) + ); +} + export function isAuthorizedAdmin( user: SessionUserLike | null | undefined, adminEmail: string | null | undefined, @@ -13,7 +31,6 @@ export function isAuthorizedAdmin( return Boolean( user && user.emailVerified === true && - adminEmail && - user.email === adminEmail, + isConfiguredAdminEmail(user.email, adminEmail), ); } diff --git a/lib/auth/server.ts b/lib/auth/server.ts index 192bea6..b13a9c7 100644 --- a/lib/auth/server.ts +++ b/lib/auth/server.ts @@ -13,7 +13,12 @@ import { verifications, } from "@/db/schema"; -import { isAuthorizedAdmin, type SessionUserLike } from "./authorization"; +import { + isAllowedAdminRegistration, + isAuthorizedAdmin, + isConfiguredAdminEmail, + type SessionUserLike, +} from "./authorization"; function required(name: string): string { const value = process.env[name]; @@ -22,6 +27,8 @@ function required(name: string): string { } function createAuth() { + const adminEmail = required("ADMIN_EMAIL"); + return betterAuth({ appName: "Buzz Sheet", database: drizzleAdapter(getDb(), { @@ -36,10 +43,36 @@ function createAuth() { }), baseURL: required("BETTER_AUTH_URL"), secret: required("BETTER_AUTH_SECRET"), - socialProviders: { - google: { - clientId: required("GOOGLE_CLIENT_ID"), - clientSecret: required("GOOGLE_CLIENT_SECRET"), + emailAndPassword: { + enabled: true, + minPasswordLength: 8, + maxPasswordLength: 128, + }, + user: { + validateUserInfo: async ({ user, source }) => { + if ( + !isAllowedAdminRegistration( + user.email, + source.method, + adminEmail, + ) + ) { + return { + error: "ADMIN_REGISTRATION_FORBIDDEN", + errorDescription: + "Registration is limited to the configured administrator.", + }; + } + }, + }, + databaseHooks: { + user: { + create: { + before: async (user) => { + if (!isConfiguredAdminEmail(user.email, adminEmail)) return false; + return { data: { emailVerified: true } }; + }, + }, }, }, plugins: [nextCookies()], diff --git a/tests/deployment-contract.test.ts b/tests/deployment-contract.test.ts index 362a337..179c3bc 100644 --- a/tests/deployment-contract.test.ts +++ b/tests/deployment-contract.test.ts @@ -133,8 +133,6 @@ describe("environment template contract", () => { "DATABASE_URL", "BETTER_AUTH_URL", "BETTER_AUTH_SECRET", - "GOOGLE_CLIENT_ID", - "GOOGLE_CLIENT_SECRET", "ADMIN_EMAIL", "REDIS_URL", "S3_ENDPOINT",