Add settings table and setting to disable registration

This commit is contained in:
corsacca 2026-04-28 15:17:26 +01:00
parent e57f9dfdd9
commit c85389f497
13 changed files with 378 additions and 4 deletions

View file

@ -46,6 +46,9 @@ const navItems = computed<NavItem[]>(() => [
: []),
...(hasPermission('roles.view')
? [{ to: '/admin/roles', label: 'Roles', icon: 'i-lucide-shield' }]
: []),
...(hasPermission('settings.view')
? [{ to: '/admin/settings', label: 'Settings', icon: 'i-lucide-settings' }]
: [])
])

View file

@ -0,0 +1,111 @@
<script setup lang="ts">
definePageMeta({
layout: 'admin',
middleware: ['auth', 'admin']
})
interface SettingsResponse {
'auth.public_registration_enabled'?: boolean
}
const { hasPermission } = usePermissions()
const toast = useToast()
const canView = computed(() => hasPermission('settings.view'))
const canEdit = computed(() => hasPermission('settings.edit'))
const { data, pending, error, refresh } = await useFetch<SettingsResponse>('/api/admin/settings', {
default: () => ({} as SettingsResponse),
immediate: canView.value
})
const registrationEnabled = computed({
get: () => data.value?.['auth.public_registration_enabled'] ?? true,
set: (val: boolean) => {
// Replace the ref value rather than mutating in place — useFetch's data
// ref doesn't reliably notify on property mutation, especially when the
// key didn't exist on the initial default empty object.
data.value = { ...(data.value ?? {}), 'auth.public_registration_enabled': val }
}
})
const saving = ref(false)
const handleToggleRegistration = async (next: boolean) => {
if (!canEdit.value || saving.value) return
const previous = registrationEnabled.value
registrationEnabled.value = next
saving.value = true
try {
await $fetch('/api/admin/settings/auth.public_registration_enabled', {
method: 'PATCH',
body: { value: next }
})
toast.add({
title: next ? 'Public registration enabled' : 'Public registration disabled',
color: 'success'
})
} catch (err: any) {
registrationEnabled.value = previous
toast.add({
title: 'Update failed',
description: err?.data?.statusMessage || err?.message || 'Failed to update setting',
color: 'error'
})
} finally {
saving.value = false
}
}
</script>
<template>
<div>
<h1 class="text-3xl font-bold mb-6">Settings</h1>
<div v-if="!canView" class="max-w-xl">
<UAlert
color="warning"
variant="soft"
title="No access"
description="You don't have permission to view site settings."
/>
</div>
<template v-else>
<UAlert v-if="error" color="error" :title="error.statusMessage || 'Failed to load settings'" class="mb-4" />
<div v-if="pending" class="text-sm text-(--ui-text-muted)">Loading…</div>
<section v-else class="max-w-2xl space-y-6">
<UCard>
<template #header>
<div class="flex items-center gap-2">
<UIcon name="i-lucide-shield-check" class="size-4 text-(--ui-text-muted)" />
<h2 class="text-sm font-semibold uppercase tracking-wide text-(--ui-text-muted)">Authentication</h2>
</div>
</template>
<div class="flex items-start justify-between gap-6">
<div class="flex-1 min-w-0">
<h3 class="font-medium">Public registration</h3>
<p class="text-sm text-(--ui-text-muted) mt-1">
When enabled, anyone can sign up at <span class="font-mono text-xs">/register</span>.
When disabled, new users must be invited by an administrator.
</p>
</div>
<USwitch
:model-value="registrationEnabled"
:disabled="!canEdit || saving"
:loading="saving"
@update:model-value="handleToggleRegistration"
/>
</div>
</UCard>
<p v-if="canView && !canEdit" class="text-xs text-(--ui-text-muted)">
You can view settings but not change them. Contact an administrator if you need to make changes.
</p>
</section>
</template>
</div>
</template>

View file

@ -31,9 +31,23 @@ onMounted(() => {
successMessage.value = 'Email verified successfully! You can now sign in.'
} else if (verified === 'already') {
successMessage.value = 'Your email is already verified. Please sign in.'
} else if (verified === 'expired') {
successMessage.value = 'That verification link has expired. Ask an administrator to send a new one.'
} else if (verified === 'invalid') {
successMessage.value = 'That verification link is invalid.'
}
})
// Public site settings — used to hide the "Create Account" CTA when an admin
// has disabled public registration.
const { data: publicSettings } = await useFetch<{ 'auth.public_registration_enabled'?: boolean }>(
'/api/settings/public',
{ default: () => ({ 'auth.public_registration_enabled': true }) }
)
const registrationEnabled = computed(() =>
publicSettings.value?.['auth.public_registration_enabled'] !== false
)
async function handleLogin() {
error.value = ''
loading.value = true
@ -187,7 +201,7 @@ function switchToReset() {
</UButton>
<!-- Divider -->
<div class="relative">
<div v-if="registrationEnabled" class="relative">
<div class="absolute inset-0 flex items-center">
<div class="w-full border-t border-(--ui-border)"></div>
</div>
@ -198,6 +212,7 @@ function switchToReset() {
<!-- Register Link -->
<UButton
v-if="registrationEnabled"
color="neutral"
variant="outline"
size="lg"

View file

@ -18,6 +18,18 @@ const loading = ref(false)
const error = ref('')
const success = ref(false)
// Public site settings — used to render a "registration disabled" notice
// when the admin has turned the toggle off. Server endpoint also rejects with
// 403, but rendering this state proactively avoids letting users fill the form
// only to be denied on submit.
const { data: publicSettings } = await useFetch<{ 'auth.public_registration_enabled'?: boolean }>(
'/api/settings/public',
{ default: () => ({ 'auth.public_registration_enabled': true }) }
)
const registrationEnabled = computed(() =>
publicSettings.value?.['auth.public_registration_enabled'] !== false
)
const passwordMatch = computed(() => {
if (!state.confirmPassword) return true
return state.password === state.confirmPassword
@ -105,8 +117,21 @@ const redirectToLogin = () => {
<!-- Register Card -->
<UCard :ui="{ body: 'p-6 sm:p-8' }">
<!-- Disabled state -->
<div v-if="!registrationEnabled" class="space-y-4">
<UAlert
color="warning"
variant="soft"
title="Public registration is disabled"
description="New accounts are added by administrator invitation. Ask your administrator to send you an invite."
/>
<UButton color="primary" size="lg" block @click="redirectToLogin">
Back to Login
</UButton>
</div>
<!-- Success Message -->
<div v-if="success" class="space-y-4">
<div v-else-if="success" class="space-y-4">
<UAlert
color="success"
variant="soft"

View file

@ -11,7 +11,9 @@ export const PERMISSIONS = [
'roles.manage',
'pages.view',
'pages.write',
'pages.publish'
'pages.publish',
'settings.view',
'settings.edit'
] as const
export type Permission = typeof PERMISSIONS[number]
@ -51,6 +53,14 @@ export const PERMISSION_META: Record<string, { title: string; description: strin
'pages.publish': {
title: 'Publish CMS pages',
description: 'Publish and unpublish CMS pages.'
},
'settings.view': {
title: 'View site settings',
description: 'See the site settings page and current values.'
},
'settings.edit': {
title: 'Edit site settings',
description: 'Change site settings such as the public registration toggle.'
}
}

View file

@ -0,0 +1,23 @@
import { Kysely, sql } from 'kysely'
export async function up(db: Kysely<any>): Promise<void> {
await db.schema
.createTable('site_settings')
.ifNotExists()
.addColumn('key', 'text', col => col.primaryKey())
.addColumn('value', 'jsonb', col => col.notNull())
.addColumn('updated', 'timestamptz', col => col.notNull().defaultTo(sql`now()`))
.execute()
// Seed the registration toggle as enabled to preserve existing behavior.
// Admins flip it off via /admin/settings after deploy if they want.
await sql`
INSERT INTO site_settings (key, value)
VALUES ('auth.public_registration_enabled', 'true'::jsonb)
ON CONFLICT (key) DO NOTHING
`.execute(db)
}
export async function down(db: Kysely<any>): Promise<void> {
await db.schema.dropTable('site_settings').ifExists().execute()
}

View file

@ -0,0 +1,32 @@
import { readBody, getRouterParam, getHeader } from 'h3'
import { requirePermission } from '../../../utils/rbac'
import { logEvent } from '../../../utils/activity-logger'
import { getSetting, setSetting, isSettingKey } from '../../../utils/site-settings'
export default defineEventHandler(async (event) => {
const admin = await requirePermission(event, 'settings.edit')
const key = getRouterParam(event, 'key')
if (!key || !isSettingKey(key)) {
throw createError({ statusCode: 404, statusMessage: 'Unknown setting key' })
}
const body = await readBody(event)
if (body === null || typeof body !== 'object' || !('value' in body)) {
throw createError({ statusCode: 400, statusMessage: 'Body must include a `value` field' })
}
const previous = await getSetting(key)
const previousReturned = await setSetting(key, body.value)
await logEvent({
eventType: 'admin_setting_changed',
tableName: 'site_settings',
recordId: key,
userId: admin.userId,
userAgent: getHeader(event, 'user-agent') || undefined,
metadata: { key, from: previousReturned ?? previous, to: body.value }
})
return { key, value: await getSetting(key) }
})

View file

@ -0,0 +1,7 @@
import { requirePermission } from '../../../utils/rbac'
import { getSettings, SETTING_KEYS } from '../../../utils/site-settings'
export default defineEventHandler(async (event) => {
await requirePermission(event, 'settings.view')
return await getSettings(SETTING_KEYS)
})

View file

@ -5,6 +5,7 @@ import { db, sql } from '../../utils/database'
import { logRegisterAttempt, logEvent } from '../../utils/activity-logger'
import { sendTemplateEmail } from '../../utils/email'
import { checkRateLimit, logRateLimitExceeded } from '../../utils/rate-limit'
import { getSetting } from '../../utils/site-settings'
import { readBody, getHeader, setResponseHeader, getRequestURL, setCookie } from 'h3'
import { useRuntimeConfig, createError } from '#imports'
import { ROLES } from '~~/app/utils/role-definitions'
@ -24,6 +25,15 @@ export default defineEventHandler(async (event) => {
throw createError({ statusCode: 400, statusMessage: 'Display name must be at least 2 characters long' })
}
// Reject before rate-limiting so disabled-registration attempts don't burn
// the IP bucket. First-user bootstrap is unaffected — a brand-new install
// never has the toggle off (default true) and admins typically only flip it
// off after at least one admin exists.
const registrationEnabled = await getSetting('auth.public_registration_enabled')
if (!registrationEnabled) {
throw createError({ statusCode: 403, statusMessage: 'Public registration is disabled' })
}
// Check rate limit by IP
const clientIp = getHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim() || 'unknown'
const userAgent = getHeader(event, 'user-agent') || undefined

View file

@ -0,0 +1,5 @@
import { getSettings, PUBLIC_SETTING_KEYS } from '../../utils/site-settings'
export default defineEventHandler(async () => {
return await getSettings(PUBLIC_SETTING_KEYS)
})

View file

@ -16,6 +16,12 @@ export interface UsersTable {
email_change_token: string | null
}
export interface SiteSettingsTable {
key: string
value: ColumnType<unknown, unknown, unknown>
updated: ColumnType<Date, string | undefined, string>
}
export interface PasswordResetRequestsTable {
id: Generated<string>
created: ColumnType<Date, Date | string | undefined, Date | string>
@ -82,6 +88,7 @@ export interface PageTranslationsTable {
export interface Database {
users: UsersTable
site_settings: SiteSettingsTable
password_reset_requests: PasswordResetRequestsTable
activity_logs: ActivityLogsTable
pages: PagesTable

View file

@ -49,7 +49,14 @@ export default defineNitroPlugin(async () => {
const migrator = new Migrator({
db: getDb(),
provider: new MultiFolderMigrationProvider(folders)
provider: new MultiFolderMigrationProvider(folders),
// Layer-supplied migrations (e.g. `oauth_001_*`) sort alphabetically AFTER
// the consumer's numeric-prefixed migrations, so any consumer migration
// added after a layer migration was applied looks "out of order" to the
// strict default checker. Allowing unordered execution is the correct
// posture for this multi-folder setup — migrations are still run exactly
// once each and must remain idempotent regardless of relative order.
allowUnorderedMigrations: true
})
// Identify pending migrations so we can announce them before execution

View file

@ -0,0 +1,119 @@
import { z } from 'zod'
import { sql } from 'kysely'
import { db } from './database'
// Single source of truth for valid keys, value shapes, defaults, and which
// keys are public-readable. Add a row here to introduce a new setting.
const settingDefs = {
'auth.public_registration_enabled': {
schema: z.boolean(),
default: true,
public: true,
},
} as const
export type SettingKey = keyof typeof settingDefs
export type SettingValue<K extends SettingKey> = z.infer<typeof settingDefs[K]['schema']>
export const SETTING_KEYS = Object.keys(settingDefs) as SettingKey[]
export const PUBLIC_SETTING_KEYS = SETTING_KEYS.filter(k => settingDefs[k].public)
export function isSettingKey(value: string): value is SettingKey {
return value in settingDefs
}
const CACHE_TTL_MS = 30_000
interface CacheEntry {
value: unknown
expiresAt: number
}
const cache = new Map<SettingKey, CacheEntry>()
function readCache(key: SettingKey): unknown | undefined {
const entry = cache.get(key)
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
cache.delete(key)
return undefined
}
return entry.value
}
function writeCache(key: SettingKey, value: unknown) {
cache.set(key, { value, expiresAt: Date.now() + CACHE_TTL_MS })
}
function parseOrDefault<K extends SettingKey>(key: K, raw: unknown): SettingValue<K> {
const def = settingDefs[key]
const result = def.schema.safeParse(raw)
if (result.success) return result.data as SettingValue<K>
return def.default as SettingValue<K>
}
export async function getSetting<K extends SettingKey>(key: K): Promise<SettingValue<K>> {
const cached = readCache(key)
if (cached !== undefined) return parseOrDefault(key, cached)
const row = await db
.selectFrom('site_settings')
.select('value')
.where('key', '=', key)
.executeTakeFirst()
const raw = row?.value
writeCache(key, raw)
return parseOrDefault(key, raw)
}
export async function getSettings<K extends SettingKey>(
keys: readonly K[]
): Promise<{ [P in K]: SettingValue<P> }> {
const result = {} as { [P in K]: SettingValue<P> }
// Sequential is fine — keys are few and most are cache hits.
for (const key of keys) {
result[key] = await getSetting(key)
}
return result
}
export async function setSetting<K extends SettingKey>(
key: K,
value: unknown
): Promise<SettingValue<K> | undefined> {
const def = settingDefs[key]
const parsed = def.schema.safeParse(value)
if (!parsed.success) {
throw createError({
statusCode: 400,
statusMessage: `Invalid value for ${key}: ${parsed.error.issues.map(i => i.message).join(', ')}`
})
}
const existing = await db
.selectFrom('site_settings')
.select('value')
.where('key', '=', key)
.executeTakeFirst()
const previous = existing ? parseOrDefault(key, existing.value) : undefined
// Inline the JSON literal in SQL rather than passing it as a bound parameter:
// postgres-js auto-stringifies JS values for unknown-typed parameters, which
// would double-encode a pre-serialized primitive (e.g. JS string "false"
// -> JSONB string "false" instead of JSONB boolean false).
const json = JSON.stringify(parsed.data)
const escaped = json.replace(/'/g, "''")
const valueLit = sql.raw(`'${escaped}'::jsonb`)
const now = new Date().toISOString()
await sql`
INSERT INTO site_settings (key, value, updated)
VALUES (${key}, ${valueLit}, ${now}::timestamptz)
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated = EXCLUDED.updated
`.execute(db)
cache.delete(key)
return previous
}