feat(admin): add registration policy settings and disabled register state
This commit is contained in:
5
TODO.md
5
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] Bootstrap first-run owner account creation via initial registration flow
|
||||||
- [x] [P1] Enforce invariant: exactly one owner user must always exist
|
- [x] [P1] Enforce invariant: exactly one owner user must always exist
|
||||||
- [x] [P1] Create hidden technical support user by default (non-demotable, non-deletable)
|
- [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] First-start onboarding route for initial owner creation (`/welcome`)
|
||||||
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
|
- [x] [P1] Split auth entry points (`/welcome`, `/login`, `/register`) with cross-links
|
||||||
- [~] [P2] Support fallback sign-in route (`/support/:key`) as break-glass access
|
- [~] [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] Authentication and session model (`admin`, `editor`, `manager`)
|
||||||
- [x] [P1] Protected admin routes and session handling
|
- [x] [P1] Protected admin routes and session handling
|
||||||
- [~] [P1] Temporary admin posts CRUD sandbox for baseline functional validation
|
- [~] [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
|
### 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] 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 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 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
|
## How We Use This File
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { AdminLocaleSwitcher } from "@/components/admin-locale-switcher"
|
|||||||
import { useAdminT } from "@/providers/admin-i18n-provider"
|
import { useAdminT } from "@/providers/admin-i18n-provider"
|
||||||
|
|
||||||
type LoginFormProps = {
|
type LoginFormProps = {
|
||||||
mode: "signin" | "signup-owner" | "signup-user"
|
mode: "signin" | "signup-owner" | "signup-user" | "signup-disabled"
|
||||||
}
|
}
|
||||||
|
|
||||||
type AuthResponse = {
|
type AuthResponse = {
|
||||||
@@ -41,6 +41,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
const [isBusy, setIsBusy] = useState(false)
|
const [isBusy, setIsBusy] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
const [success, setSuccess] = useState<string | null>(null)
|
||||||
|
const canSubmitSignUp = mode === "signup-owner" || mode === "signup-user"
|
||||||
|
|
||||||
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
|
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -141,7 +142,9 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
? t("auth.titles.signIn", "Sign in to CMS Admin")
|
? t("auth.titles.signIn", "Sign in to CMS Admin")
|
||||||
: mode === "signup-owner"
|
: mode === "signup-owner"
|
||||||
? t("auth.titles.signUpOwner", "Welcome to CMS Admin")
|
? 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")}
|
||||||
</h1>
|
</h1>
|
||||||
<p className="text-sm text-neutral-600">
|
<p className="text-sm text-neutral-600">
|
||||||
{mode === "signin"
|
{mode === "signin"
|
||||||
@@ -151,7 +154,12 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
"auth.descriptions.signUpOwner",
|
"auth.descriptions.signUpOwner",
|
||||||
"Create the first owner account to initialize this admin instance.",
|
"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.",
|
||||||
|
)}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -208,7 +216,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
|
|
||||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
</form>
|
</form>
|
||||||
) : (
|
) : canSubmitSignUp ? (
|
||||||
<form
|
<form
|
||||||
onSubmit={handleSignUp}
|
onSubmit={handleSignUp}
|
||||||
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
|
className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6"
|
||||||
@@ -290,6 +298,20 @@ export function LoginForm({ mode }: LoginFormProps) {
|
|||||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||||
{success ? <p className="text-sm text-green-700">{success}</p> : null}
|
{success ? <p className="text-sm text-green-700">{success}</p> : null}
|
||||||
</form>
|
</form>
|
||||||
|
) : (
|
||||||
|
<section className="mt-8 space-y-4 rounded-xl border border-neutral-200 p-6">
|
||||||
|
<p className="text-sm text-neutral-700">
|
||||||
|
{t(
|
||||||
|
"auth.messages.registrationDisabled",
|
||||||
|
"Registration is disabled for this admin instance. Ask an administrator to create an account or enable self-registration.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-neutral-600">
|
||||||
|
<Link href={`/login?next=${encodeURIComponent(nextPath)}`} className="underline">
|
||||||
|
{t("auth.links.goToSignIn", "Go to sign in")}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
)}
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -200,6 +200,12 @@ export default async function AdminHomePage({
|
|||||||
>
|
>
|
||||||
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
|
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
|
||||||
</Link>
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/settings"
|
||||||
|
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("settings.title", "Settings")}
|
||||||
|
</Link>
|
||||||
<LogoutButton />
|
<LogoutButton />
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Sea
|
|||||||
const enabled = await isSelfRegistrationEnabled()
|
const enabled = await isSelfRegistrationEnabled()
|
||||||
|
|
||||||
if (!enabled) {
|
if (!enabled) {
|
||||||
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
|
return <LoginForm mode="signup-disabled" />
|
||||||
}
|
}
|
||||||
|
|
||||||
return <LoginForm mode="signup-user" />
|
return <LoginForm mode="signup-user" />
|
||||||
|
|||||||
188
apps/admin/src/app/settings/page.tsx
Normal file
188
apps/admin/src/app/settings/page.tsx
Normal file
@@ -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<Record<string, string | string[] | undefined>>
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<main className="mx-auto flex min-h-screen w-full max-w-4xl flex-col gap-8 px-6 py-16">
|
||||||
|
<header className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<p className="text-sm uppercase tracking-[0.2em] text-neutral-500">
|
||||||
|
{t("settings.badge", "Admin Settings")}
|
||||||
|
</p>
|
||||||
|
<AdminLocaleSwitcher />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-4xl font-semibold tracking-tight">{t("settings.title", "Settings")}</h1>
|
||||||
|
<p className="text-neutral-600">
|
||||||
|
{t(
|
||||||
|
"settings.description",
|
||||||
|
"Manage runtime policies for the admin authentication and onboarding flow.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3 pt-2">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="inline-flex rounded-md border border-neutral-300 px-4 py-2 text-sm font-medium hover:bg-neutral-100"
|
||||||
|
>
|
||||||
|
{t("settings.actions.backToDashboard", "Back to dashboard")}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{notice ? (
|
||||||
|
<section className="rounded-xl border border-emerald-300 bg-emerald-50 px-4 py-3 text-sm text-emerald-800">
|
||||||
|
{notice}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<section className="rounded-xl border border-red-300 bg-red-50 px-4 py-3 text-sm text-red-800">
|
||||||
|
{error}
|
||||||
|
</section>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<section className="rounded-xl border border-neutral-200 p-6">
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h2 className="text-xl font-medium">
|
||||||
|
{t("settings.registration.title", "Admin self-registration")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t(
|
||||||
|
"settings.registration.description",
|
||||||
|
"When enabled, /register can create additional admin accounts after initial owner bootstrap.",
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-lg border border-neutral-200 p-4 text-sm text-neutral-700">
|
||||||
|
<p>
|
||||||
|
{t("settings.registration.currentStatusLabel", "Current status")}:{" "}
|
||||||
|
<strong>
|
||||||
|
{isRegistrationEnabled
|
||||||
|
? t("settings.registration.status.enabled", "Enabled")
|
||||||
|
: t("settings.registration.status.disabled", "Disabled")}
|
||||||
|
</strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form action={updateRegistrationPolicyAction} className="space-y-4">
|
||||||
|
<label className="flex items-center gap-3 text-sm">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="enabled"
|
||||||
|
defaultChecked={isRegistrationEnabled}
|
||||||
|
className="h-4 w-4 rounded border-neutral-300"
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
{t(
|
||||||
|
"settings.registration.checkboxLabel",
|
||||||
|
"Allow self-registration on /register for admin users",
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<Button type="submit">
|
||||||
|
{t("settings.registration.actions.save", "Save registration policy")}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,11 +19,13 @@ const messages: AdminMessages = {
|
|||||||
signIn: "Sign in",
|
signIn: "Sign in",
|
||||||
signUpOwner: "Welcome",
|
signUpOwner: "Welcome",
|
||||||
signUpUser: "Create account",
|
signUpUser: "Create account",
|
||||||
|
signUpDisabled: "Registration disabled",
|
||||||
},
|
},
|
||||||
descriptions: {
|
descriptions: {
|
||||||
signIn: "Sign in description",
|
signIn: "Sign in description",
|
||||||
signUpOwner: "Owner description",
|
signUpOwner: "Owner description",
|
||||||
signUpUser: "User description",
|
signUpUser: "User description",
|
||||||
|
signUpDisabled: "Disabled description",
|
||||||
},
|
},
|
||||||
fields: {
|
fields: {
|
||||||
name: "Name",
|
name: "Name",
|
||||||
@@ -48,6 +50,7 @@ const messages: AdminMessages = {
|
|||||||
messages: {
|
messages: {
|
||||||
ownerCreated: "Owner account created.",
|
ownerCreated: "Owner account created.",
|
||||||
accountCreated: "Account created.",
|
accountCreated: "Account created.",
|
||||||
|
registrationDisabled: "Registration is disabled.",
|
||||||
},
|
},
|
||||||
errors: {
|
errors: {
|
||||||
nameRequired: "Name is required.",
|
nameRequired: "Name is required.",
|
||||||
@@ -57,6 +60,33 @@ const messages: AdminMessages = {
|
|||||||
networkSignUp: "Network sign up error",
|
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: {
|
dashboard: {
|
||||||
badge: "Admin App",
|
badge: "Admin App",
|
||||||
title: "Content Dashboard",
|
title: "Content Dashboard",
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ const guardRules: GuardRule[] = [
|
|||||||
scope: "global",
|
scope: "global",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
route: /^\/settings(?:\/|$)/,
|
||||||
|
requirement: {
|
||||||
|
permission: "users:manage_roles",
|
||||||
|
scope: "global",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
route: /^\/(?:$|\?)/,
|
route: /^\/(?:$|\?)/,
|
||||||
requirement: {
|
requirement: {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { normalizeRole, type Role } from "@cms/content/rbac"
|
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 { betterAuth } from "better-auth"
|
||||||
import { prismaAdapter } from "better-auth/adapters/prisma"
|
import { prismaAdapter } from "better-auth/adapters/prisma"
|
||||||
import { toNextJsHandler } from "better-auth/next-js"
|
import { toNextJsHandler } from "better-auth/next-js"
|
||||||
@@ -43,8 +43,7 @@ export async function isInitialOwnerRegistrationOpen(): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function isSelfRegistrationEnabled(): Promise<boolean> {
|
export async function isSelfRegistrationEnabled(): Promise<boolean> {
|
||||||
// Temporary fallback until registration policy is managed from admin settings.
|
return isAdminSelfRegistrationEnabled()
|
||||||
return process.env.CMS_ADMIN_SELF_REGISTRATION_ENABLED === "true"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function canUserSelfRegister(): Promise<boolean> {
|
export async function canUserSelfRegister(): Promise<boolean> {
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"titles": {
|
"titles": {
|
||||||
"signIn": "Bei CMS Admin anmelden",
|
"signIn": "Bei CMS Admin anmelden",
|
||||||
"signUpOwner": "Willkommen bei CMS Admin",
|
"signUpOwner": "Willkommen bei CMS Admin",
|
||||||
"signUpUser": "Admin-Konto erstellen"
|
"signUpUser": "Admin-Konto erstellen",
|
||||||
|
"signUpDisabled": "Registrierung ist deaktiviert"
|
||||||
},
|
},
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"signIn": "Better Auth ist in dieser App über /api/auth aktiv.",
|
"signIn": "Better Auth ist in dieser App über /api/auth aktiv.",
|
||||||
"signUpOwner": "Erstelle das erste Owner-Konto, um diese Admin-Instanz zu initialisieren.",
|
"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": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -42,7 +44,8 @@
|
|||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"ownerCreated": "Owner-Konto erstellt. Registrierung ist jetzt deaktiviert.",
|
"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": {
|
"errors": {
|
||||||
"nameRequired": "Name ist für die Kontoerstellung erforderlich",
|
"nameRequired": "Name ist für die Kontoerstellung erforderlich",
|
||||||
@@ -52,6 +55,33 @@
|
|||||||
"networkSignUp": "Netzwerkfehler bei der Registrierung"
|
"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": {
|
"dashboard": {
|
||||||
"badge": "Admin-App",
|
"badge": "Admin-App",
|
||||||
"title": "Content-Dashboard",
|
"title": "Content-Dashboard",
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"titles": {
|
"titles": {
|
||||||
"signIn": "Sign in to CMS Admin",
|
"signIn": "Sign in to CMS Admin",
|
||||||
"signUpOwner": "Welcome to CMS Admin",
|
"signUpOwner": "Welcome to CMS Admin",
|
||||||
"signUpUser": "Create an admin account"
|
"signUpUser": "Create an admin account",
|
||||||
|
"signUpDisabled": "Registration is disabled"
|
||||||
},
|
},
|
||||||
"descriptions": {
|
"descriptions": {
|
||||||
"signIn": "Better Auth is active on this app via /api/auth.",
|
"signIn": "Better Auth is active on this app via /api/auth.",
|
||||||
"signUpOwner": "Create the first owner account to initialize this admin instance.",
|
"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": {
|
"fields": {
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
@@ -42,7 +44,8 @@
|
|||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"ownerCreated": "Owner account created. Registration is now disabled.",
|
"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": {
|
"errors": {
|
||||||
"nameRequired": "Name is required for account creation",
|
"nameRequired": "Name is required for account creation",
|
||||||
@@ -52,6 +55,33 @@
|
|||||||
"networkSignUp": "Network error while signing up"
|
"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": {
|
"dashboard": {
|
||||||
"badge": "Admin App",
|
"badge": "Admin App",
|
||||||
"title": "Content Dashboard",
|
"title": "Content Dashboard",
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"titles": {
|
"titles": {
|
||||||
"signIn": "Iniciar sesión en CMS Admin",
|
"signIn": "Iniciar sesión en CMS Admin",
|
||||||
"signUpOwner": "Bienvenido a 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": {
|
"descriptions": {
|
||||||
"signIn": "Better Auth está activo en esta app mediante /api/auth.",
|
"signIn": "Better Auth está activo en esta app mediante /api/auth.",
|
||||||
"signUpOwner": "Crea la primera cuenta owner para inicializar esta instancia de administración.",
|
"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": {
|
"fields": {
|
||||||
"name": "Nombre",
|
"name": "Nombre",
|
||||||
@@ -42,7 +44,8 @@
|
|||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"ownerCreated": "Cuenta owner creada. El registro ahora está deshabilitado.",
|
"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": {
|
"errors": {
|
||||||
"nameRequired": "El nombre es obligatorio para crear la cuenta",
|
"nameRequired": "El nombre es obligatorio para crear la cuenta",
|
||||||
@@ -52,6 +55,33 @@
|
|||||||
"networkSignUp": "Error de red al registrarse"
|
"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": {
|
"dashboard": {
|
||||||
"badge": "App Admin",
|
"badge": "App Admin",
|
||||||
"title": "Panel de Contenido",
|
"title": "Panel de Contenido",
|
||||||
|
|||||||
@@ -13,12 +13,14 @@
|
|||||||
"titles": {
|
"titles": {
|
||||||
"signIn": "Se connecter à CMS Admin",
|
"signIn": "Se connecter à CMS Admin",
|
||||||
"signUpOwner": "Bienvenue sur 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": {
|
"descriptions": {
|
||||||
"signIn": "Better Auth est actif sur cette application via /api/auth.",
|
"signIn": "Better Auth est actif sur cette application via /api/auth.",
|
||||||
"signUpOwner": "Créez le premier compte owner pour initialiser cette instance d’administration.",
|
"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": {
|
"fields": {
|
||||||
"name": "Nom",
|
"name": "Nom",
|
||||||
@@ -42,7 +44,8 @@
|
|||||||
},
|
},
|
||||||
"messages": {
|
"messages": {
|
||||||
"ownerCreated": "Compte owner créé. L’inscription est maintenant désactivée.",
|
"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": {
|
"errors": {
|
||||||
"nameRequired": "Le nom est requis pour créer un compte",
|
"nameRequired": "Le nom est requis pour créer un compte",
|
||||||
@@ -52,6 +55,33 @@
|
|||||||
"networkSignUp": "Erreur réseau lors de l’inscription"
|
"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": {
|
"dashboard": {
|
||||||
"badge": "Application Admin",
|
"badge": "Application Admin",
|
||||||
"title": "Tableau de bord contenu",
|
"title": "Tableau de bord contenu",
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ Optional:
|
|||||||
|
|
||||||
- Support user bootstrap is available via `bun run auth:seed:support`.
|
- Support user bootstrap is available via `bun run auth:seed:support`.
|
||||||
- Root `bun run db:seed` runs DB seed and support-user seed.
|
- 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`.
|
- 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.
|
- Email verification and forgot/reset password pipelines are tracked for MVP2.
|
||||||
|
|||||||
@@ -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")
|
||||||
|
);
|
||||||
@@ -87,3 +87,12 @@ model Verification {
|
|||||||
@@index([identifier])
|
@@index([identifier])
|
||||||
@@map("verification")
|
@@map("verification")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model SystemSetting {
|
||||||
|
key String @id
|
||||||
|
value String
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
|
@@map("system_setting")
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,3 +7,4 @@ export {
|
|||||||
registerPostCrudAuditHook,
|
registerPostCrudAuditHook,
|
||||||
updatePost,
|
updatePost,
|
||||||
} from "./posts"
|
} from "./posts"
|
||||||
|
export { isAdminSelfRegistrationEnabled, setAdminSelfRegistrationEnabled } from "./settings"
|
||||||
|
|||||||
56
packages/db/src/settings.ts
Normal file
56
packages/db/src/settings.ts
Normal file
@@ -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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ const buttonVariants = cva(
|
|||||||
{
|
{
|
||||||
variants: {
|
variants: {
|
||||||
variant: {
|
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",
|
secondary: "bg-neutral-100 text-neutral-900 hover:bg-neutral-200",
|
||||||
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
|
ghost: "hover:bg-neutral-100 hover:text-neutral-900",
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user