diff --git a/.env.example b/.env.example
index a8b14c5..ab7928d 100644
--- a/.env.example
+++ b/.env.example
@@ -9,6 +9,7 @@ DATABASE_POOL_SIZE=10
# Better Auth email/password administrator login
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=replace-with-at-least-32-random-bytes
+# Separate multiple administrator addresses with commas.
ADMIN_EMAIL=admin@example.com
# Redis remote cache, event transport, and outbox worker
diff --git a/Plan.md b/Plan.md
index a50d2d6..6b6f732 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 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.
+- Authenticate with Better Auth email and password. Permit registration and admin access only when the credential account email exactly matches an address in the comma-separated `ADMIN_EMAIL` configuration; 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.
diff --git a/README.md b/README.md
index 8c03cc0..a4d5952 100644
--- a/README.md
+++ b/README.md
@@ -68,8 +68,9 @@ 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 the exact `ADMIN_EMAIL`, then use `/admin/login` for later access.
-The registration endpoint rejects every other email; remove the temporary page
+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.
Prepare the database and start the application:
diff --git a/components/admin/register-card.tsx b/components/admin/register-card.tsx
index 83d1481..c2b3c3c 100644
--- a/components/admin/register-card.tsx
+++ b/components/admin/register-card.tsx
@@ -103,7 +103,7 @@ export function RegisterCard() {
หน้าลงทะเบียนชั่วคราว
- ระบบรับเฉพาะอีเมลที่ตรงกับ ADMIN_EMAIL เท่านั้น
+ ระบบรับเฉพาะอีเมลที่ระบุไว้ใน ADMIN_EMAIL เท่านั้น
ควรนำหน้านี้ออกหลังสร้างบัญชีสำเร็จ
diff --git a/lib/auth/authorization.test.ts b/lib/auth/authorization.test.ts
index 61f975d..dcd70ca 100644
--- a/lib/auth/authorization.test.ts
+++ b/lib/auth/authorization.test.ts
@@ -23,6 +23,21 @@ describe("admin authorization", () => {
expect(isConfiguredAdminEmail(verified.email, undefined)).toBe(false);
});
+ it("supports a comma-separated administrator email list", () => {
+ expect(
+ isConfiguredAdminEmail(
+ "second@example.com",
+ "admin@example.com, second@example.com",
+ ),
+ ).toBe(true);
+ expect(
+ isConfiguredAdminEmail(
+ "other@example.com",
+ "admin@example.com, second@example.com",
+ ),
+ ).toBe(false);
+ });
+
it("allows only credential registration for the configured administrator", () => {
expect(
isAllowedAdminRegistration(
diff --git a/lib/auth/authorization.ts b/lib/auth/authorization.ts
index cd11f56..4900d4e 100644
--- a/lib/auth/authorization.ts
+++ b/lib/auth/authorization.ts
@@ -8,9 +8,15 @@ export interface SessionUserLike {
export function isConfiguredAdminEmail(
email: string | null | undefined,
- adminEmail: string | null | undefined,
+ adminEmailConfig: string | null | undefined,
): boolean {
- return Boolean(email && adminEmail && email === adminEmail);
+ if (!email || !adminEmailConfig) return false;
+
+ return adminEmailConfig
+ .split(",")
+ .map((configuredEmail) => configuredEmail.trim())
+ .filter(Boolean)
+ .includes(email);
}
export function isAllowedAdminRegistration(