Add settings table and setting to disable registration
This commit is contained in:
parent
e57f9dfdd9
commit
c85389f497
13 changed files with 378 additions and 4 deletions
|
|
@ -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' }]
|
||||
: [])
|
||||
])
|
||||
|
||||
|
|
|
|||
111
app/pages/admin/settings.vue
Normal file
111
app/pages/admin/settings.vue
Normal 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>
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
23
migrations/012_create_site_settings.ts
Normal file
23
migrations/012_create_site_settings.ts
Normal 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()
|
||||
}
|
||||
32
server/api/admin/settings/[key].patch.ts
Normal file
32
server/api/admin/settings/[key].patch.ts
Normal 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) }
|
||||
})
|
||||
7
server/api/admin/settings/index.get.ts
Normal file
7
server/api/admin/settings/index.get.ts
Normal 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)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
5
server/api/settings/public.get.ts
Normal file
5
server/api/settings/public.get.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { getSettings, PUBLIC_SETTING_KEYS } from '../../utils/site-settings'
|
||||
|
||||
export default defineEventHandler(async () => {
|
||||
return await getSettings(PUBLIC_SETTING_KEYS)
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
119
server/utils/site-settings.ts
Normal file
119
server/utils/site-settings.ts
Normal 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
|
||||
}
|
||||
Loading…
Reference in a new issue