diff --git a/TODO.md b/TODO.md
index 97913f8..ee0fa67 100644
--- a/TODO.md
+++ b/TODO.md
@@ -28,7 +28,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Bootstrap first-run owner account creation via initial registration flow
- [x] [P1] Enforce invariant: exactly one owner user must always exist
- [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
-- [~] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
+- [x] [P1] Admin registration policy control (allow/deny self-registration for admin panel)
- [x] [P1] First-start onboarding route for initial owner creation (`/welcome`)
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
@@ -45,7 +45,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [x] [P1] Authentication and session model (`admin`, `editor`, `manager`)
- [x] [P1] Protected admin routes and session handling
- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
-- [ ] [P1] Core admin IA (pages/media/users/commissions/settings)
+- [~] [P1] Core admin IA (pages/media/users/commissions/settings)
### Public App
@@ -198,6 +198,7 @@ This file is the single source of truth for roadmap and delivery progress.
- [2026-02-10] Shared CRUD base (`@cms/crud`) is live with validation, not-found errors, and audit hook contracts; only posts are migrated so far.
- [2026-02-10] Admin dashboard includes a temporary posts CRUD sandbox (create/update/delete) to validate the shared CRUD base through the real app UI.
- [2026-02-10] Admin i18n baseline now resolves locale from cookie and loads runtime message dictionaries in root layout; admin locale switcher is active on auth and dashboard views.
+- [2026-02-10] Admin self-registration policy is now managed via `/settings` and persisted in `system_setting`; env var is fallback/default only.
## How We Use This File
diff --git a/apps/admin/src/app/login/login-form.tsx b/apps/admin/src/app/login/login-form.tsx
index df017ec..473d81a 100644
--- a/apps/admin/src/app/login/login-form.tsx
+++ b/apps/admin/src/app/login/login-form.tsx
@@ -8,7 +8,7 @@ import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
import { useAdminT } from "@/providers/admin-i18n-provider"
type LoginFormProps = {
- mode: "signin" | "signup-owner" | "signup-user"
+ mode: "signin" | "signup-owner" | "signup-user" | "signup-disabled"
}
type AuthResponse = {
@@ -41,6 +41,7 @@ export function LoginForm({ mode }: LoginFormProps) {
const [isBusy, setIsBusy] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(null)
+ const canSubmitSignUp = mode === "signup-owner" || mode === "signup-user"
async function handleSignIn(event: FormEvent) {
event.preventDefault()
@@ -141,7 +142,9 @@ export function LoginForm({ mode }: LoginFormProps) {
? t("auth.titles.signIn", "Sign in to CMS Admin")
: mode === "signup-owner"
? t("auth.titles.signUpOwner", "Welcome to CMS Admin")
- : t("auth.titles.signUpUser", "Create an admin account")}
+ : mode === "signup-user"
+ ? t("auth.titles.signUpUser", "Create an admin account")
+ : t("auth.titles.signUpDisabled", "Registration is disabled")}
{mode === "signin"
@@ -151,7 +154,12 @@ export function LoginForm({ mode }: LoginFormProps) {
"auth.descriptions.signUpOwner",
"Create the first owner account to initialize this admin instance.",
)
- : t("auth.descriptions.signUpUser", "Self-registration is enabled for admin users.")}
+ : mode === "signup-user"
+ ? t("auth.descriptions.signUpUser", "Self-registration is enabled for admin users.")
+ : t(
+ "auth.descriptions.signUpDisabled",
+ "Self-registration is currently turned off by an administrator.",
+ )}
@@ -208,7 +216,7 @@ export function LoginForm({ mode }: LoginFormProps) {
{error ? {error}
: null}
- ) : (
+ ) : canSubmitSignUp ? (
: null}
{success ? {success}
: null}
+ ) : (
+
+
+ {t(
+ "auth.messages.registrationDisabled",
+ "Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration.",
+ )}
+
+
+
+ {t("auth.links.goToSignIn", "Go to sign in")}
+
+
+
)}
)
diff --git a/apps/admin/src/app/page.tsx b/apps/admin/src/app/page.tsx
index b55c5a2..03e3216 100644
--- a/apps/admin/src/app/page.tsx
+++ b/apps/admin/src/app/page.tsx
@@ -200,6 +200,12 @@ export default async function AdminHomePage({
>
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
+
+ {t("settings.title", "Settings")}
+
diff --git a/apps/admin/src/app/register/page.tsx b/apps/admin/src/app/register/page.tsx
index 648587b..2790154 100644
--- a/apps/admin/src/app/register/page.tsx
+++ b/apps/admin/src/app/register/page.tsx
@@ -33,7 +33,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Sea
const enabled = await isSelfRegistrationEnabled()
if (!enabled) {
- redirect(`/login?next=${encodeURIComponent(nextPath)}`)
+ return
}
return
diff --git a/apps/admin/src/app/settings/page.tsx b/apps/admin/src/app/settings/page.tsx
new file mode 100644
index 0000000..e1793d0
--- /dev/null
+++ b/apps/admin/src/app/settings/page.tsx
@@ -0,0 +1,188 @@
+import { hasPermission } from "@cms/content/rbac"
+import { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "@cms/db"
+import { Button } from "@cms/ui/button"
+import { revalidatePath } from "next/cache"
+import Link from "next/link"
+import { redirect } from "next/navigation"
+
+import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
+import { translateMessage } from "@/i18n/messages"
+import { getAdminMessages, resolveAdminLocale } from "@/i18n/server"
+import { resolveRoleFromServerContext } from "@/lib/access-server"
+
+type SearchParamsInput = Promise>
+
+function toSingleValue(input: string | string[] | undefined): string | null {
+ if (Array.isArray(input)) {
+ return input[0] ?? null
+ }
+
+ return input ?? null
+}
+
+async function requireSettingsPermission() {
+ const role = await resolveRoleFromServerContext()
+
+ if (!role) {
+ redirect("/login?next=/settings")
+ }
+
+ if (!hasPermission(role, "users:manage_roles", "global")) {
+ redirect("/unauthorized?required=users:manage_roles&scope=global")
+ }
+}
+
+async function getSettingsTranslator() {
+ const locale = await resolveAdminLocale()
+ const messages = await getAdminMessages(locale)
+
+ return (key: string, fallback: string) => translateMessage(messages, key, fallback)
+}
+
+async function updateRegistrationPolicyAction(formData: FormData) {
+ "use server"
+
+ await requireSettingsPermission()
+ const t = await getSettingsTranslator()
+ const enabled = formData.get("enabled") === "on"
+
+ try {
+ await setAdminSelfRegistrationEnabled(enabled)
+ } catch (error) {
+ const errorMessage = error instanceof Error ? error.message : ""
+ const normalizedMessage = errorMessage.toLowerCase()
+ const isDatabaseUnavailable = errorMessage.includes("P1001")
+ const isSchemaMissing =
+ errorMessage.includes("P2021") ||
+ normalizedMessage.includes("system_setting") ||
+ normalizedMessage.includes("does not exist")
+
+ const userMessage = isDatabaseUnavailable
+ ? t(
+ "settings.registration.errors.databaseUnavailable",
+ "Saving settings failed. The database is currently unreachable.",
+ )
+ : isSchemaMissing
+ ? t(
+ "settings.registration.errors.schemaMissing",
+ "Saving settings failed. Apply the latest database migrations and try again.",
+ )
+ : t(
+ "settings.registration.errors.updateFailed",
+ "Saving settings failed. Ensure database migrations are applied.",
+ )
+
+ redirect(`/settings?error=${encodeURIComponent(userMessage)}`)
+ }
+
+ revalidatePath("/settings")
+ revalidatePath("/register")
+ redirect(
+ `/settings?notice=${encodeURIComponent(
+ t("settings.registration.success.updated", "Registration policy updated."),
+ )}`,
+ )
+}
+
+export default async function SettingsPage({ searchParams }: { searchParams: SearchParamsInput }) {
+ await requireSettingsPermission()
+
+ const [params, locale, isRegistrationEnabled] = await Promise.all([
+ searchParams,
+ resolveAdminLocale(),
+ isAdminSelfRegistrationEnabled(),
+ ])
+ const messages = await getAdminMessages(locale)
+ const t = (key: string, fallback: string) => translateMessage(messages, key, fallback)
+
+ const notice = toSingleValue(params.notice)
+ const error = toSingleValue(params.error)
+
+ return (
+
+
+
+
+ {t("settings.badge", "Admin Settings")}
+
+
+
+ {t("settings.title", "Settings")}
+
+ {t(
+ "settings.description",
+ "Manage runtime policies for the admin authentication and onboarding flow.",
+ )}
+
+
+
+ {t("settings.actions.backToDashboard", "Back to dashboard")}
+
+
+
+
+ {notice ? (
+
+ ) : null}
+
+ {error ? (
+
+ ) : null}
+
+
+
+
+
+ {t("settings.registration.title", "Admin self-registration")}
+
+
+ {t(
+ "settings.registration.description",
+ "When enabled, /register can create additional admin accounts after initial owner bootstrap.",
+ )}
+
+
+
+
+
+ {t("settings.registration.currentStatusLabel", "Current status")}:{" "}
+
+ {isRegistrationEnabled
+ ? t("settings.registration.status.enabled", "Enabled")
+ : t("settings.registration.status.disabled", "Disabled")}
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/apps/admin/src/i18n/messages.test.ts b/apps/admin/src/i18n/messages.test.ts
index 4b2e987..6f9852e 100644
--- a/apps/admin/src/i18n/messages.test.ts
+++ b/apps/admin/src/i18n/messages.test.ts
@@ -19,11 +19,13 @@ const messages: AdminMessages = {
signIn: "Sign in",
signUpOwner: "Welcome",
signUpUser: "Create account",
+ signUpDisabled: "Registration disabled",
},
descriptions: {
signIn: "Sign in description",
signUpOwner: "Owner description",
signUpUser: "User description",
+ signUpDisabled: "Disabled description",
},
fields: {
name: "Name",
@@ -48,6 +50,7 @@ const messages: AdminMessages = {
messages: {
ownerCreated: "Owner account created.",
accountCreated: "Account created.",
+ registrationDisabled: "Registration is disabled.",
},
errors: {
nameRequired: "Name is required.",
@@ -57,6 +60,33 @@ const messages: AdminMessages = {
networkSignUp: "Network sign up error",
},
},
+ settings: {
+ badge: "Admin Settings",
+ title: "Settings",
+ description: "Settings description",
+ actions: {
+ backToDashboard: "Back to dashboard",
+ },
+ registration: {
+ title: "Registration",
+ description: "Registration description",
+ currentStatusLabel: "Current status",
+ status: {
+ enabled: "Enabled",
+ disabled: "Disabled",
+ },
+ checkboxLabel: "Allow registration",
+ actions: {
+ save: "Save",
+ },
+ success: {
+ updated: "Updated",
+ },
+ errors: {
+ updateFailed: "Update failed",
+ },
+ },
+ },
dashboard: {
badge: "Admin App",
title: "Content Dashboard",
diff --git a/apps/admin/src/lib/access.ts b/apps/admin/src/lib/access.ts
index 068ca14..dec492c 100644
--- a/apps/admin/src/lib/access.ts
+++ b/apps/admin/src/lib/access.ts
@@ -43,6 +43,13 @@ const guardRules: GuardRule[] = [
scope: "global",
},
},
+ {
+ route: /^\/settings(?:\/|$)/,
+ requirement: {
+ permission: "users:manage_roles",
+ scope: "global",
+ },
+ },
{
route: /^\/(?:$|\?)/,
requirement: {
diff --git a/apps/admin/src/lib/auth/server.ts b/apps/admin/src/lib/auth/server.ts
index e12fb11..8707f79 100644
--- a/apps/admin/src/lib/auth/server.ts
+++ b/apps/admin/src/lib/auth/server.ts
@@ -1,5 +1,5 @@
import { normalizeRole, type Role } from "@cms/content/rbac"
-import { db } from "@cms/db"
+import { db, isAdminSelfRegistrationEnabled } from "@cms/db"
import { betterAuth } from "better-auth"
import { prismaAdapter } from "better-auth/adapters/prisma"
import { toNextJsHandler } from "better-auth/next-js"
@@ -43,8 +43,7 @@ export async function isInitialOwnerRegistrationOpen(): Promise {
}
export async function isSelfRegistrationEnabled(): Promise {
- // Temporary fallback until registration policy is managed from admin settings.
- return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
+ return isAdminSelfRegistrationEnabled()
}
export async function canUserSelfRegister(): Promise {
diff --git a/apps/admin/src/messages/de.json b/apps/admin/src/messages/de.json
index 267ff3c..7554210 100644
--- a/apps/admin/src/messages/de.json
+++ b/apps/admin/src/messages/de.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Bei CMS Admin anmelden",
"signUpOwner": "Willkommen bei CMS Admin",
- "signUpUser": "Admin-Konto erstellen"
+ "signUpUser": "Admin-Konto erstellen",
+ "signUpDisabled": "Registrierung ist deaktiviert"
},
"descriptions": {
"signIn": "Better Auth ist in dieser App über /api/auth aktiv.",
"signUpOwner": "Erstelle das erste Owner-Konto, um diese Admin-Instanz zu initialisieren.",
- "signUpUser": "Selbstregistrierung für Admin-Benutzer ist aktiviert."
+ "signUpUser": "Selbstregistrierung für Admin-Benutzer ist aktiviert.",
+ "signUpDisabled": "Selbstregistrierung wurde von einer Administratorin oder einem Administrator deaktiviert."
},
"fields": {
"name": "Name",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Owner-Konto erstellt. Registrierung ist jetzt deaktiviert.",
- "accountCreated": "Konto erstellt."
+ "accountCreated": "Konto erstellt.",
+ "registrationDisabled": "Für diese Admin-Instanz ist die Registrierung deaktiviert. Bitte wende dich an eine Administratorin oder einen Administrator."
},
"errors": {
"nameRequired": "Name ist für die Kontoerstellung erforderlich",
@@ -52,6 +55,33 @@
"networkSignUp": "Netzwerkfehler bei der Registrierung"
}
},
+ "settings": {
+ "badge": "Admin-Einstellungen",
+ "title": "Einstellungen",
+ "description": "Verwalte Laufzeitrichtlinien für Authentifizierung und Onboarding im Admin-Bereich.",
+ "actions": {
+ "backToDashboard": "Zurück zum Dashboard"
+ },
+ "registration": {
+ "title": "Admin-Selbstregistrierung",
+ "description": "Wenn aktiviert, können über /register nach der initialen Owner-Erstellung weitere Admin-Konten erstellt werden.",
+ "currentStatusLabel": "Aktueller Status",
+ "status": {
+ "enabled": "Aktiviert",
+ "disabled": "Deaktiviert"
+ },
+ "checkboxLabel": "Selbstregistrierung auf /register für Admin-Benutzer erlauben",
+ "actions": {
+ "save": "Registrierungsrichtlinie speichern"
+ },
+ "success": {
+ "updated": "Registrierungsrichtlinie aktualisiert."
+ },
+ "errors": {
+ "updateFailed": "Speichern der Einstellungen fehlgeschlagen. Stelle sicher, dass Datenbankmigrationen angewendet wurden."
+ }
+ }
+ },
"dashboard": {
"badge": "Admin-App",
"title": "Content-Dashboard",
diff --git a/apps/admin/src/messages/en.json b/apps/admin/src/messages/en.json
index b23adfb..6507ce4 100644
--- a/apps/admin/src/messages/en.json
+++ b/apps/admin/src/messages/en.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Sign in to CMS Admin",
"signUpOwner": "Welcome to CMS Admin",
- "signUpUser": "Create an admin account"
+ "signUpUser": "Create an admin account",
+ "signUpDisabled": "Registration is disabled"
},
"descriptions": {
"signIn": "Better Auth is active on this app via /api/auth.",
"signUpOwner": "Create the first owner account to initialize this admin instance.",
- "signUpUser": "Self-registration is enabled for admin users."
+ "signUpUser": "Self-registration is enabled for admin users.",
+ "signUpDisabled": "Self-registration is currently turned off by an administrator."
},
"fields": {
"name": "Name",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Owner account created. Registration is now disabled.",
- "accountCreated": "Account created."
+ "accountCreated": "Account created.",
+ "registrationDisabled": "Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration."
},
"errors": {
"nameRequired": "Name is required for account creation",
@@ -52,6 +55,33 @@
"networkSignUp": "Network error while signing up"
}
},
+ "settings": {
+ "badge": "Admin Settings",
+ "title": "Settings",
+ "description": "Manage runtime policies for the admin authentication and onboarding flow.",
+ "actions": {
+ "backToDashboard": "Back to dashboard"
+ },
+ "registration": {
+ "title": "Admin self-registration",
+ "description": "When enabled, /register can create additional admin accounts after initial owner bootstrap.",
+ "currentStatusLabel": "Current status",
+ "status": {
+ "enabled": "Enabled",
+ "disabled": "Disabled"
+ },
+ "checkboxLabel": "Allow self-registration on /register for admin users",
+ "actions": {
+ "save": "Save registration policy"
+ },
+ "success": {
+ "updated": "Registration policy updated."
+ },
+ "errors": {
+ "updateFailed": "Saving settings failed. Ensure database migrations are applied."
+ }
+ }
+ },
"dashboard": {
"badge": "Admin App",
"title": "Content Dashboard",
diff --git a/apps/admin/src/messages/es.json b/apps/admin/src/messages/es.json
index 52764f3..698e634 100644
--- a/apps/admin/src/messages/es.json
+++ b/apps/admin/src/messages/es.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Iniciar sesión en CMS Admin",
"signUpOwner": "Bienvenido a CMS Admin",
- "signUpUser": "Crear una cuenta de admin"
+ "signUpUser": "Crear una cuenta de admin",
+ "signUpDisabled": "El registro está deshabilitado"
},
"descriptions": {
"signIn": "Better Auth está activo en esta app mediante /api/auth.",
"signUpOwner": "Crea la primera cuenta owner para inicializar esta instancia de administración.",
- "signUpUser": "El registro automático está habilitado para usuarios admin."
+ "signUpUser": "El registro automático está habilitado para usuarios admin.",
+ "signUpDisabled": "El auto-registro está desactivado actualmente por un administrador."
},
"fields": {
"name": "Nombre",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Cuenta owner creada. El registro ahora está deshabilitado.",
- "accountCreated": "Cuenta creada."
+ "accountCreated": "Cuenta creada.",
+ "registrationDisabled": "El registro está deshabilitado para esta instancia de administración. Pide a un administrador que cree una cuenta o habilite el auto-registro."
},
"errors": {
"nameRequired": "El nombre es obligatorio para crear la cuenta",
@@ -52,6 +55,33 @@
"networkSignUp": "Error de red al registrarse"
}
},
+ "settings": {
+ "badge": "Ajustes de Admin",
+ "title": "Ajustes",
+ "description": "Gestiona políticas de ejecución para autenticación y onboarding del panel admin.",
+ "actions": {
+ "backToDashboard": "Volver al panel"
+ },
+ "registration": {
+ "title": "Auto-registro de admin",
+ "description": "Cuando está habilitado, /register puede crear cuentas admin adicionales después del bootstrap inicial del owner.",
+ "currentStatusLabel": "Estado actual",
+ "status": {
+ "enabled": "Habilitado",
+ "disabled": "Deshabilitado"
+ },
+ "checkboxLabel": "Permitir auto-registro en /register para usuarios admin",
+ "actions": {
+ "save": "Guardar política de registro"
+ },
+ "success": {
+ "updated": "Política de registro actualizada."
+ },
+ "errors": {
+ "updateFailed": "No se pudieron guardar los ajustes. Asegúrate de que las migraciones de base de datos estén aplicadas."
+ }
+ }
+ },
"dashboard": {
"badge": "App Admin",
"title": "Panel de Contenido",
diff --git a/apps/admin/src/messages/fr.json b/apps/admin/src/messages/fr.json
index 85acb74..50daaf6 100644
--- a/apps/admin/src/messages/fr.json
+++ b/apps/admin/src/messages/fr.json
@@ -13,12 +13,14 @@
"titles": {
"signIn": "Se connecter à CMS Admin",
"signUpOwner": "Bienvenue sur CMS Admin",
- "signUpUser": "Créer un compte admin"
+ "signUpUser": "Créer un compte admin",
+ "signUpDisabled": "L’inscription est désactivée"
},
"descriptions": {
"signIn": "Better Auth est actif sur cette application via /api/auth.",
"signUpOwner": "Créez le premier compte owner pour initialiser cette instance d’administration.",
- "signUpUser": "L’auto-inscription est activée pour les utilisateurs admin."
+ "signUpUser": "L’auto-inscription est activée pour les utilisateurs admin.",
+ "signUpDisabled": "L’auto-inscription est actuellement désactivée par un administrateur."
},
"fields": {
"name": "Nom",
@@ -42,7 +44,8 @@
},
"messages": {
"ownerCreated": "Compte owner créé. L’inscription est maintenant désactivée.",
- "accountCreated": "Compte créé."
+ "accountCreated": "Compte créé.",
+ "registrationDisabled": "L’inscription est désactivée pour cette instance admin. Demandez à un administrateur de créer un compte ou de réactiver l’auto-inscription."
},
"errors": {
"nameRequired": "Le nom est requis pour créer un compte",
@@ -52,6 +55,33 @@
"networkSignUp": "Erreur réseau lors de l’inscription"
}
},
+ "settings": {
+ "badge": "Paramètres Admin",
+ "title": "Paramètres",
+ "description": "Gérez les politiques d’exécution pour l’authentification et l’onboarding de l’admin.",
+ "actions": {
+ "backToDashboard": "Retour au tableau de bord"
+ },
+ "registration": {
+ "title": "Auto-inscription admin",
+ "description": "Lorsqu’elle est activée, /register peut créer des comptes admin supplémentaires après l’initialisation du premier owner.",
+ "currentStatusLabel": "Statut actuel",
+ "status": {
+ "enabled": "Activé",
+ "disabled": "Désactivé"
+ },
+ "checkboxLabel": "Autoriser l’auto-inscription sur /register pour les utilisateurs admin",
+ "actions": {
+ "save": "Enregistrer la politique d’inscription"
+ },
+ "success": {
+ "updated": "Politique d’inscription mise à jour."
+ },
+ "errors": {
+ "updateFailed": "Échec de l’enregistrement des paramètres. Vérifiez que les migrations de base de données sont appliquées."
+ }
+ }
+ },
"dashboard": {
"badge": "Application Admin",
"title": "Tableau de bord contenu",
diff --git a/docs/product-engineering/auth-baseline.md b/docs/product-engineering/auth-baseline.md
index d604b2b..7a01497 100644
--- a/docs/product-engineering/auth-baseline.md
+++ b/docs/product-engineering/auth-baseline.md
@@ -39,6 +39,7 @@ Optional:
- Support user bootstrap is available via `bun run auth:seed:support`.
- Root `bun run db:seed` runs DB seed and support-user seed.
-- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is temporary until admin settings UI manages this policy.
+- `CMS_ADMIN_SELF_REGISTRATION_ENABLED` is now a fallback/default only.
+- Runtime source of truth is admin settings (`/settings`) backed by `system_setting`.
- Owner/support checks for future admin user-management mutations remain tracked in `TODO.md`.
- Email verification and forgot/reset password pipelines are tracked for MVP2.
diff --git a/packages/db/prisma/migrations/20260210200148_admin_registration_policy_setting/migration.sql b/packages/db/prisma/migrations/20260210200148_admin_registration_policy_setting/migration.sql
new file mode 100644
index 0000000..4dbabbc
--- /dev/null
+++ b/packages/db/prisma/migrations/20260210200148_admin_registration_policy_setting/migration.sql
@@ -0,0 +1,9 @@
+-- CreateTable
+CREATE TABLE "system_setting" (
+ "key" TEXT NOT NULL,
+ "value" TEXT NOT NULL,
+ "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "updatedAt" TIMESTAMP(3) NOT NULL,
+
+ CONSTRAINT "system_setting_pkey" PRIMARY KEY ("key")
+);
diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma
index 33ce8dd..2154806 100644
--- a/packages/db/prisma/schema.prisma
+++ b/packages/db/prisma/schema.prisma
@@ -87,3 +87,12 @@ model Verification {
@@index([identifier])
@@map("verification")
}
+
+model SystemSetting {
+ key String @id
+ value String
+ createdAt DateTime @default(now())
+ updatedAt DateTime @updatedAt
+
+ @@map("system_setting")
+}
diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts
index 91418cb..4f14908 100644
--- a/packages/db/src/index.ts
+++ b/packages/db/src/index.ts
@@ -7,3 +7,4 @@ export {
registerPostCrudAuditHook,
updatePost,
} from "./posts"
+export { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "./settings"
diff --git a/packages/db/src/settings.ts b/packages/db/src/settings.ts
new file mode 100644
index 0000000..fb3cd12
--- /dev/null
+++ b/packages/db/src/settings.ts
@@ -0,0 +1,56 @@
+import { db } from "./client"
+
+const ADMIN_SELF_REGISTRATION_KEY = "admin.self_registration_enabled"
+
+function resolveEnvFallback(): boolean {
+ return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
+}
+
+function parseStoredBoolean(value: string): boolean | null {
+ if (value === "true") {
+ return true
+ }
+
+ if (value === "false") {
+ return false
+ }
+
+ return null
+}
+
+export async function isAdminSelfRegistrationEnabled(): Promise {
+ try {
+ const setting = await db.systemSetting.findUnique({
+ where: { key: ADMIN_SELF_REGISTRATION_KEY },
+ select: { value: true },
+ })
+
+ if (!setting) {
+ return resolveEnvFallback()
+ }
+
+ const parsed = parseStoredBoolean(setting.value)
+
+ if (parsed === null) {
+ return resolveEnvFallback()
+ }
+
+ return parsed
+ } catch {
+ // Fallback while migrations are not yet applied in a local environment.
+ return resolveEnvFallback()
+ }
+}
+
+export async function setAdminSelfRegistrationEnabled(enabled: boolean): Promise {
+ await db.systemSetting.upsert({
+ where: { key: ADMIN_SELF_REGISTRATION_KEY },
+ create: {
+ key: ADMIN_SELF_REGISTRATION_KEY,
+ value: enabled ? "true" : "false",
+ },
+ update: {
+ value: enabled ? "true" : "false",
+ },
+ })
+}
diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx
index be5462f..ab10d0e 100644
--- a/packages/ui/src/components/button.tsx
+++ b/packages/ui/src/components/button.tsx
@@ -8,7 +8,7 @@ const buttonVariants = cva(
{
variants: {
variant: {
- default: "bg-neutral-900 text-neutral-50 hover:bg-neutral-800",
+ default: "bg-neutral-900 text-white hover:bg-neutral-800",
secondary: "bg-neutral-100 text-neutral-900 hover:bg-neutral-200",
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
},