diff --git a/app/layouts/admin.vue b/app/layouts/admin.vue index fb218be6..b7e49e67 100644 --- a/app/layouts/admin.vue +++ b/app/layouts/admin.vue @@ -46,6 +46,9 @@ const navItems = computed(() => [ : []), ...(hasPermission('roles.view') ? [{ to: '/admin/roles', label: 'Roles', icon: 'i-lucide-shield' }] + : []), + ...(hasPermission('settings.view') + ? [{ to: '/admin/settings', label: 'Settings', icon: 'i-lucide-settings' }] : []) ]) diff --git a/app/pages/admin/settings.vue b/app/pages/admin/settings.vue new file mode 100644 index 00000000..ee7cbdbb --- /dev/null +++ b/app/pages/admin/settings.vue @@ -0,0 +1,111 @@ + + + diff --git a/app/pages/login.vue b/app/pages/login.vue index 105544f5..bf77bd75 100644 --- a/app/pages/login.vue +++ b/app/pages/login.vue @@ -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() { -
+
@@ -198,6 +212,7 @@ function switchToReset() { ( + '/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 = () => { + +
+ + + Back to Login + +
+ -
+
): Promise { + 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): Promise { + await db.schema.dropTable('site_settings').ifExists().execute() +} diff --git a/server/api/admin/settings/[key].patch.ts b/server/api/admin/settings/[key].patch.ts new file mode 100644 index 00000000..81c46bed --- /dev/null +++ b/server/api/admin/settings/[key].patch.ts @@ -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) } +}) diff --git a/server/api/admin/settings/index.get.ts b/server/api/admin/settings/index.get.ts new file mode 100644 index 00000000..c2dd5740 --- /dev/null +++ b/server/api/admin/settings/index.get.ts @@ -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) +}) diff --git a/server/api/auth/register.post.ts b/server/api/auth/register.post.ts index f2d6d510..43afc8fe 100644 --- a/server/api/auth/register.post.ts +++ b/server/api/auth/register.post.ts @@ -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 diff --git a/server/api/settings/public.get.ts b/server/api/settings/public.get.ts new file mode 100644 index 00000000..9d3d230b --- /dev/null +++ b/server/api/settings/public.get.ts @@ -0,0 +1,5 @@ +import { getSettings, PUBLIC_SETTING_KEYS } from '../../utils/site-settings' + +export default defineEventHandler(async () => { + return await getSettings(PUBLIC_SETTING_KEYS) +}) diff --git a/server/database/schema.ts b/server/database/schema.ts index 208da406..5ea2c864 100644 --- a/server/database/schema.ts +++ b/server/database/schema.ts @@ -16,6 +16,12 @@ export interface UsersTable { email_change_token: string | null } +export interface SiteSettingsTable { + key: string + value: ColumnType + updated: ColumnType +} + export interface PasswordResetRequestsTable { id: Generated created: ColumnType @@ -82,6 +88,7 @@ export interface PageTranslationsTable { export interface Database { users: UsersTable + site_settings: SiteSettingsTable password_reset_requests: PasswordResetRequestsTable activity_logs: ActivityLogsTable pages: PagesTable diff --git a/server/plugins/migrations.ts b/server/plugins/migrations.ts index 546469bf..2784fdc5 100644 --- a/server/plugins/migrations.ts +++ b/server/plugins/migrations.ts @@ -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 diff --git a/server/utils/site-settings.ts b/server/utils/site-settings.ts new file mode 100644 index 00000000..402075a0 --- /dev/null +++ b/server/utils/site-settings.ts @@ -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 = z.infer + +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() + +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(key: K, raw: unknown): SettingValue { + const def = settingDefs[key] + const result = def.schema.safeParse(raw) + if (result.success) return result.data as SettingValue + return def.default as SettingValue +} + +export async function getSetting(key: K): Promise> { + 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( + keys: readonly K[] +): Promise<{ [P in K]: SettingValue

}> { + const result = {} as { [P in K]: SettingValue

} + // 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( + key: K, + value: unknown +): Promise | 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 +}