feat(admin): add registration policy settings and disabled register state
This commit is contained in:
@@ -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<string | null>(null)
|
||||
const [success, setSuccess] = useState<string | null>(null)
|
||||
const canSubmitSignUp = mode === "signup-owner" || mode === "signup-user"
|
||||
|
||||
async function handleSignIn(event: FormEvent<HTMLFormElement>) {
|
||||
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")}
|
||||
</h1>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{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.",
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -208,7 +216,7 @@ export function LoginForm({ mode }: LoginFormProps) {
|
||||
|
||||
{error ? <p className="text-sm text-red-600">{error}</p> : null}
|
||||
</form>
|
||||
) : (
|
||||
) : canSubmitSignUp ? (
|
||||
<form
|
||||
onSubmit={handleSignUp}
|
||||
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}
|
||||
{success ? <p className="text-sm text-green-700">{success}</p> : null}
|
||||
</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>
|
||||
)
|
||||
|
||||
@@ -200,6 +200,12 @@ export default async function AdminHomePage({
|
||||
>
|
||||
{t("dashboard.actions.openRoadmap", "Open roadmap and progress")}
|
||||
</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 />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -33,7 +33,7 @@ export default async function RegisterPage({ searchParams }: { searchParams: Sea
|
||||
const enabled = await isSelfRegistrationEnabled()
|
||||
|
||||
if (!enabled) {
|
||||
redirect(`/login?next=${encodeURIComponent(nextPath)}`)
|
||||
return <LoginForm mode="signup-disabled" />
|
||||
}
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user