diff --git a/.env.example b/.env.example index e4461bd..7177f8c 100644 --- a/.env.example +++ b/.env.example @@ -11,8 +11,6 @@ BETTER_AUTH_URL=http://localhost:3000 BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes # Separate trusted browser origins with commas for local development or proxies. BETTER_AUTH_TRUSTED_ORIGINS=http://localhost:3000 -# Separate multiple administrator addresses with commas. -ADMIN_EMAIL=admin@example.com # Redis remote cache, event transport, and outbox worker REDIS_URL=rediss://default:replace-me@redis.example.internal:6379 diff --git a/README.md b/README.md index 92ba1eb..8fc055f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Production target: `https://guide.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. -- Better Auth email/password login restricted to the configured `ADMIN_EMAIL`. +- Better Auth email/password login with administrator-managed accounts. - Private S3-compatible media uploads with reference-aware same-origin delivery. - PostgreSQL transactions, immutable data-source versions, revisions, and a retryable outbox. @@ -39,7 +39,7 @@ formula fixtures. Their guide text and media are not imported or published. | `/[character]` | Redirect to the first visible page | | `/[character]/[page]` | Render a public guide page | | `/admin/login` | Administrator email/password sign-in | -| `/register` | Temporary administrator account bootstrap | +| `/admin/register` | Create and remove administrator accounts | | `/admin` | Character and page overview | | `/admin/[character]/[page]` | Visual page editor | | `/admin/create` | Create a structured guide from the synced character catalog | @@ -68,11 +68,8 @@ cp .env.example .env `.env.example` contains placeholders only. Configure `.env` yourself; it is 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 an address listed in `ADMIN_EMAIL`, then use `/admin/login` for -later access. Separate multiple administrator addresses with commas. The -registration endpoint rejects every other email; remove the temporary page -after the administrator account has been created. +match the application origin. Existing administrators can create additional +credential accounts from `/admin/register`; public registration is disabled. Prepare the database and start the application: @@ -216,7 +213,6 @@ Before the first rollout, a cluster administrator must provision a ```text DATABASE_URL BETTER_AUTH_SECRET -ADMIN_EMAIL REDIS_URL S3_ENDPOINT S3_PUBLIC_URL diff --git a/app/admin/register/actions.ts b/app/admin/register/actions.ts new file mode 100644 index 0000000..b9dbe9f --- /dev/null +++ b/app/admin/register/actions.ts @@ -0,0 +1,87 @@ +"use server"; + +import { eq } from "drizzle-orm"; +import { headers } from "next/headers"; +import { revalidatePath } from "next/cache"; +import { z } from "zod"; + +import { getDb } from "@/db"; +import { users } from "@/db/schema"; +import { getAuth, requireAdmin } from "@/lib/auth/server"; + +export interface CreateAccountState { + status: "idle" | "error" | "success"; + message: string; +} + +export const initialCreateAccountState: CreateAccountState = { + status: "idle", + message: "", +}; + +const accountSchema = z + .object({ + name: z.string().trim().min(1), + email: z.email().transform((value) => value.toLowerCase()), + password: z.string().min(8).max(128), + passwordConfirmation: z.string(), + }) + .refine((data) => data.password === data.passwordConfirmation, { + path: ["passwordConfirmation"], + }); + +export async function createAccount( + _previousState: CreateAccountState, + formData: FormData, +): Promise { + await requireAdmin(); + const parsed = accountSchema.safeParse(Object.fromEntries(formData)); + if (!parsed.success) { + return { status: "error", message: "โปรดตรวจสอบชื่อ อีเมล และรหัสผ่านให้ถูกต้อง" }; + } + + const existing = await getDb().query.users.findFirst({ + columns: { id: true }, + where: eq(users.email, parsed.data.email), + }); + if (existing) { + return { status: "error", message: "อีเมลนี้มีบัญชีอยู่แล้ว" }; + } + + try { + await getAuth().api.createUser({ + body: { + name: parsed.data.name, + email: parsed.data.email, + password: parsed.data.password, + }, + headers: await headers(), + }); + } catch { + return { status: "error", message: "สร้างบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" }; + } + + revalidatePath("/admin/register"); + return { status: "success", message: `สร้างบัญชี ${parsed.data.email} แล้ว` }; +} + +export async function removeAccount( + userId: string, +): Promise<{ status: "error" | "success"; message?: string }> { + const session = await requireAdmin(); + if (userId === session.user.id) { + return { status: "error", message: "ไม่สามารถลบบัญชีที่กำลังใช้งานอยู่" }; + } + + try { + await getAuth().api.removeUser({ + body: { userId }, + headers: await headers(), + }); + } catch { + return { status: "error", message: "ลบบัญชีไม่สำเร็จ โปรดลองอีกครั้ง" }; + } + + revalidatePath("/admin/register"); + return { status: "success" }; +} diff --git a/app/admin/register/page.tsx b/app/admin/register/page.tsx new file mode 100644 index 0000000..6966998 --- /dev/null +++ b/app/admin/register/page.tsx @@ -0,0 +1,41 @@ +import { asc } from "drizzle-orm"; +import { redirect } from "next/navigation"; +import { connection } from "next/server"; + +import { AdminHeader } from "@/components/admin/admin-header"; +import { RegisterCard } from "@/components/admin/register-card"; +import { getDb } from "@/db"; +import { users } from "@/db/schema"; +import { getAdminSession } from "@/lib/auth/server"; + +export default async function RegisterPage() { + await connection(); + const session = await getAdminSession(); + if (!session) redirect("/admin/login"); + + const accounts = await getDb() + .select({ + id: users.id, + name: users.name, + email: users.email, + createdAt: users.createdAt, + }) + .from(users) + .orderBy(asc(users.createdAt)); + + return ( +
+ +
+
+

Admin access

+

จัดการบัญชี

+

+ สร้างบัญชีให้ผู้ดูแลคนอื่น หรือลบบัญชีที่ไม่ต้องใช้งานแล้ว +

+
+ +
+
+ ); +} diff --git a/app/register/page.tsx b/app/register/page.tsx index d9fe37a..176a33f 100644 --- a/app/register/page.tsx +++ b/app/register/page.tsx @@ -1,20 +1,5 @@ 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"); - if (await !getAdminSession()) redirect("/"); - return ( null - //
- // - //
- // - //
- //
- ); + redirect("/admin/register"); } diff --git a/components/admin/admin-header.tsx b/components/admin/admin-header.tsx index 8f34024..15e4d82 100644 --- a/components/admin/admin-header.tsx +++ b/components/admin/admin-header.tsx @@ -8,6 +8,7 @@ import { LayoutDashboardIcon, LogOutIcon, PlusIcon, + UserPlusIcon, } from "lucide-react"; import { Button } from "@/components/ui/button"; @@ -28,6 +29,15 @@ export function AdminHeader() { ไกด์ช่องเกนชินไม่ใช่เกมมือถือ