From 2fb71b4ae18e27d3647e451a8a0b5e5f416b50ff Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Tue, 28 Apr 2026 20:56:52 +0530 Subject: [PATCH] feat: runtime self-registration toggle in admin dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-registration is now controlled at runtime from the admin panel rather than at build time via an env var. Default: off. - New `settings` table (key/value/updated_at) plus getSetting/setSetting helpers. Idempotent first-boot migration seeds allow_self_registration from the ALLOW_SELF_REGISTRATION env var; after first boot the env var is ignored and admins control the toggle from the UI. - New public GET /api/auth/config (no auth) — returns { allowSelfRegistration, hasUsers }. The Login page polls this on mount to decide whether to show a Register link, and to render "Create the first admin account" mode when the install is empty. - New admin GET /api/admin/settings + PUT /api/admin/settings/:key for the dashboard. Constrained to a known-keys allowlist with type coercion so unrecognized keys can't be stored. - POST /api/auth/register now reads the toggle from the database instead of process.env. The first user is still always allowed and is auto- promoted to admin. - Admin.tsx grows a "Settings" card with a labelled toggle switch and toast feedback. The card sits above the user table. - VITE_ALLOW_SELF_REGISTRATION dropped — runtime fetch replaces it. Docs: README + .env.example clarify that ALLOW_SELF_REGISTRATION is now an initial seed only, the going-public checklist points at the dashboard toggle, and the features list calls out runtime control. --- .env.example | 9 ++-- README.md | 6 +-- backend/db.js | 46 +++++++++++++++++++ backend/routes/admin.js | 57 +++++++++++++++++++++++ backend/routes/auth.js | 21 ++++++++- docker-compose.yml | 2 +- frontend/src/pages/Admin.tsx | 89 ++++++++++++++++++++++++++++++++++-- frontend/src/pages/Login.tsx | 26 +++++++---- 8 files changed, 234 insertions(+), 22 deletions(-) diff --git a/.env.example b/.env.example index 4316910..c79de53 100644 --- a/.env.example +++ b/.env.example @@ -43,16 +43,13 @@ SEED_ADMIN_USERNAME= SEED_ADMIN_DISPLAY_NAME= # ---- Self-registration ---- -# When "true", the public /api/auth/register endpoint accepts new signups. -# When "false" (default), only admins can create accounts (via /api/admin/users). +# Initial value for the runtime self-registration toggle. Default "false". +# After the first boot this env var is IGNORED — admins control the toggle +# from the dashboard at /admin (it's persisted in the database). # The very first user can always register, regardless of this flag, and is # auto-promoted to admin. ALLOW_SELF_REGISTRATION=false -# Frontend mirror — controls whether the Login screen shows the "Register" link. -# Must be set at *build* time (Vite) for the bundled frontend, not at runtime. -VITE_ALLOW_SELF_REGISTRATION=false - # ---- Optional: API key for bot / programmatic upload ---- # If set, /api/upload/api-key endpoints require this header value. REFBOARD_API_KEY= diff --git a/README.md b/README.md index 2fb783a..a49833d 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ Built because we needed PureRef's painlessness, Miro's collaboration, and a code - First user is auto-admin - `SEED_ADMIN_*` env vars to bootstrap an admin on first boot - `ALLOW_SELF_REGISTRATION` flag — when off, only admins can create accounts -- **Admin dashboard** at `/admin` — list users, create accounts, reset passwords, promote/demote between admin and member, deactivate / reactivate (admin-only, JWT-gated) +- **Admin dashboard** at `/admin` — list users, create accounts, reset passwords, promote/demote between admin and member, deactivate / reactivate, and toggle self-registration on or off at runtime (admin-only, JWT-gated) - Admin REST endpoints work with either a JWT belonging to an admin user or an `X-API-Key` header for bots **Deployment** @@ -140,7 +140,7 @@ All knobs live in `.env`. See [`.env.example`](.env.example) for the full annota | `DB_PATH` | no | SQLite file path. Defaults to `/app/data/refboard.db` (Docker). | | `MINIO_ENDPOINT` / `_PORT` / `_ACCESS_KEY` / `_SECRET_KEY` / `_BUCKET` | yes | S3-compatible storage. | | `SEED_ADMIN_EMAIL` + `SEED_ADMIN_PASSWORD` | no | Idempotent first-boot admin bootstrap. | -| `ALLOW_SELF_REGISTRATION` | no | When `true`, anyone can register. Default `false`. | +| `ALLOW_SELF_REGISTRATION` | no | Initial seed only — sets the runtime toggle on first boot. After that, control it from the admin dashboard. Default `false`. | | `MAX_FILE_SIZE_MB` | no | Per-file upload cap. Default 200. | | `REFBOARD_API_KEY` | no | Enables programmatic upload via X-API-Key header. | @@ -241,7 +241,7 @@ Anyone in your tailnet can hit it; no one else can. - [ ] Set `JWT_SECRET` to a real random value (`openssl rand -base64 64`). - [ ] Set `NODE_ENV=production` (the backend refuses to start in prod without `JWT_SECRET`). - [ ] Set `CORS_ORIGIN=https://your.domain` (drop the wildcard). -- [ ] Set `ALLOW_SELF_REGISTRATION=false` and pre-create accounts via the admin endpoints. +- [ ] Confirm self-registration is **off** in the admin dashboard (defaults off; only flips on if you set `ALLOW_SELF_REGISTRATION=true` on first boot). - [ ] Restrict the MinIO console (port 9001) to localhost — only the S3 API on 9000 needs to be reachable from the backend, and the backend already proxies media bytes through `/api/images/*`, so MinIO does **not** need to be exposed publicly. - [ ] Keep `./.docker-data/` (or `DB_PATH` + MinIO data dir) backed up — that's all your state. diff --git a/backend/db.js b/backend/db.js index 4bdcf83..c05da98 100644 --- a/backend/db.js +++ b/backend/db.js @@ -207,6 +207,50 @@ catch { db.exec('ALTER TABLE images ADD COLUMN page_count INTEGER'); } try { db.prepare('SELECT priority FROM media_jobs LIMIT 0').get(); } catch { db.exec('ALTER TABLE media_jobs ADD COLUMN priority INTEGER DEFAULT 0'); } +// --------------------- +// Settings (runtime-tunable key/value pairs) +// --------------------- +db.exec(` + CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + ); +`); + +function getSetting(key) { + const row = db.prepare('SELECT value FROM settings WHERE key = ?').get(key); + return row ? row.value : null; +} + +function setSetting(key, value) { + db.prepare(` + INSERT INTO settings (key, value, updated_at) + VALUES (?, ?, datetime('now')) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now') + `).run(key, String(value)); +} + +function getAllSettings() { + return db.prepare('SELECT key, value, updated_at FROM settings').all(); +} + +function getBoolSetting(key, defaultValue = false) { + const v = getSetting(key); + if (v === null) return defaultValue; + return v === 'true' || v === '1'; +} + +// First-boot: seed allow_self_registration from env var if it has never been +// stored. After first boot, the env var is ignored — admins control it from +// the dashboard. +(function seedSettingsFromEnv() { + if (getSetting('allow_self_registration') === null) { + const envValue = (process.env.ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true'; + setSetting('allow_self_registration', envValue ? 'true' : 'false'); + } +})(); + // --------------------- // User helpers // --------------------- @@ -701,6 +745,8 @@ module.exports = { incrementThreadCommentCount, decrementThreadCommentCount, // Comments getCommentsByThread, getCommentsByBoard, getComment, createComment, updateComment, deleteComment, + // Settings + getSetting, setSetting, getAllSettings, getBoolSetting, // Bootstrap seedAdminFromEnv, }; diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 3d968bd..6260954 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -15,12 +15,69 @@ const { updateCollection, addCollectionMember, createBoard, + getAllSettings, + setSetting, + getBoolSetting, } = require('../db'); const router = Router(); router.use(adminOrApiKeyMiddleware); +// ---- Settings ---- + +const KNOWN_SETTINGS = { + allow_self_registration: { type: 'bool', default: false }, +}; + +router.get('/settings', (_req, res) => { + try { + const stored = getAllSettings(); + const map = Object.fromEntries(stored.map((s) => [s.key, s])); + const out = {}; + for (const [key, def] of Object.entries(KNOWN_SETTINGS)) { + const row = map[key]; + let value; + if (def.type === 'bool') { + value = row ? row.value === 'true' || row.value === '1' : def.default; + } else { + value = row ? row.value : def.default; + } + out[key] = { value, updated_at: row?.updated_at || null, type: def.type }; + } + return res.json({ settings: out }); + } catch (err) { + console.error('[admin] get settings error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + +router.put('/settings/:key', (req, res) => { + try { + const { key } = req.params; + const def = KNOWN_SETTINGS[key]; + if (!def) { + return res.status(404).json({ error: `Unknown setting: ${key}` }); + } + const raw = req.body?.value; + if (raw === undefined || raw === null) { + return res.status(400).json({ error: 'Missing "value" in request body' }); + } + let stringValue; + if (def.type === 'bool') { + const b = raw === true || raw === 'true' || raw === 1 || raw === '1'; + stringValue = b ? 'true' : 'false'; + } else { + stringValue = String(raw); + } + setSetting(key, stringValue); + return res.json({ key, value: def.type === 'bool' ? stringValue === 'true' : stringValue }); + } catch (err) { + console.error('[admin] set setting error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + // ---- User management ---- router.post('/users', async (req, res) => { diff --git a/backend/routes/auth.js b/backend/routes/auth.js index 4d3a29c..e56719b 100644 --- a/backend/routes/auth.js +++ b/backend/routes/auth.js @@ -6,6 +6,7 @@ const { createUser, getUserById, getUserCount, + getBoolSetting, } = require('../db'); const { hashPassword, @@ -16,6 +17,24 @@ const { const router = Router(); +/** + * GET /api/auth/config + * Public, no auth — used by the Login page to decide whether to show the + * "Register" link. Only exposes booleans the UI needs; no internals. + */ +router.get('/config', (_req, res) => { + try { + const userCount = getUserCount(); + return res.json({ + allowSelfRegistration: getBoolSetting('allow_self_registration', false), + hasUsers: userCount > 0, + }); + } catch (err) { + console.error('[auth] config error:', err); + return res.status(500).json({ error: 'Internal server error' }); + } +}); + /** * POST /api/auth/register * Create a new user. The first user automatically becomes admin. @@ -32,8 +51,8 @@ router.post('/register', async (req, res) => { return res.status(400).json({ error: 'Password must be at least 6 characters' }); } - const allowRegistration = (process.env.ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true'; const userCount = getUserCount(); + const allowRegistration = getBoolSetting('allow_self_registration', false); if (!allowRegistration && userCount > 0) { return res.status(403).json({ error: 'Self-registration is disabled. Ask an admin to create your account.', diff --git a/docker-compose.yml b/docker-compose.yml index 4568a09..3492879 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -43,7 +43,7 @@ services: SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-} SEED_ADMIN_USERNAME: ${SEED_ADMIN_USERNAME:-} SEED_ADMIN_DISPLAY_NAME: ${SEED_ADMIN_DISPLAY_NAME:-} - ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false} + ALLOW_SELF_REGISTRATION: ${ALLOW_SELF_REGISTRATION:-false} # initial seed only; toggle lives in /admin REFBOARD_API_KEY: ${REFBOARD_API_KEY:-} ports: - "8000:8000" diff --git a/frontend/src/pages/Admin.tsx b/frontend/src/pages/Admin.tsx index 5316354..32a1af4 100644 --- a/frontend/src/pages/Admin.tsx +++ b/frontend/src/pages/Admin.tsx @@ -14,6 +14,12 @@ interface AdminUser { updated_at: string; } +interface SettingsResponse { + settings: { + allow_self_registration?: { value: boolean; updated_at: string | null; type: string }; + }; +} + const inputStyle: React.CSSProperties = { padding: '10px 12px', background: '#0d0d0d', @@ -201,20 +207,26 @@ export default function Admin() { const [showCreate, setShowCreate] = useState(false); const [resetFor, setResetFor] = useState(null); const [toast, setToast] = useState<{ msg: string; kind: 'ok' | 'err' } | null>(null); + const [allowSelfReg, setAllowSelfReg] = useState(null); + const [allowSelfRegSaving, setAllowSelfRegSaving] = useState(false); const refresh = useCallback(async () => { setLoadErr(''); try { - const res = await api.get('/api/admin/users'); - const list: AdminUser[] = res.data?.users || []; + const [usersRes, settingsRes] = await Promise.all([ + api.get('/api/admin/users'), + api.get('/api/admin/settings'), + ]); + const list: AdminUser[] = usersRes.data?.users || []; list.sort((a, b) => { if (a.is_active !== b.is_active) return b.is_active - a.is_active; if (a.role !== b.role) return a.role === 'admin' ? -1 : 1; return a.email.localeCompare(b.email); }); setUsers(list); + setAllowSelfReg(!!settingsRes.data?.settings?.allow_self_registration?.value); } catch (e: any) { - setLoadErr(e?.response?.data?.error || 'Failed to load users'); + setLoadErr(e?.response?.data?.error || 'Failed to load admin data'); } finally { setLoading(false); } @@ -224,6 +236,26 @@ export default function Admin() { if (!authLoading) refresh(); }, [authLoading, refresh]); + async function toggleSelfRegistration() { + if (allowSelfReg === null || allowSelfRegSaving) return; + const next = !allowSelfReg; + setAllowSelfRegSaving(true); + try { + await api.put('/api/admin/settings/allow_self_registration', { value: next }); + setAllowSelfReg(next); + setToast({ + msg: next + ? 'Self-registration enabled — anyone can sign up from the login page' + : 'Self-registration disabled — only admins can create accounts', + kind: 'ok', + }); + } catch (e: any) { + setToast({ msg: e?.response?.data?.error || 'Failed to update setting', kind: 'err' }); + } finally { + setAllowSelfRegSaving(false); + } + } + if (authLoading) { return
Loading…
; } @@ -317,6 +349,27 @@ export default function Admin() { + {/* Settings card */} +
+
+
+ Allow self-registration +
+
+ When on, anyone with the URL can create an account. When off, only admins can create accounts (the Register link disappears from the login page). +
+
+ +
+
void; +}) { + return ( + + ); +} + function ActionButton({ children, onClick, disabled, danger }: { children: React.ReactNode; onClick: () => void; diff --git a/frontend/src/pages/Login.tsx b/frontend/src/pages/Login.tsx index 48f53a2..14ce6a1 100644 --- a/frontend/src/pages/Login.tsx +++ b/frontend/src/pages/Login.tsx @@ -1,10 +1,8 @@ -import React, { useState } from 'react'; +import React, { useState, useEffect } from 'react'; import { useNavigate } from 'react-router-dom'; import { useAuth } from '../auth'; import api from '../api'; -const ALLOW_REGISTER = (import.meta.env.VITE_ALLOW_SELF_REGISTRATION || '').toLowerCase() === 'true'; - export default function Login() { const [mode, setMode] = useState<'login' | 'register'>('login'); const [email, setEmail] = useState(''); @@ -13,13 +11,25 @@ export default function Login() { const [password, setPassword] = useState(''); const [error, setError] = useState(''); const [loading, setLoading] = useState(false); + const [allowRegister, setAllowRegister] = useState(false); + const [hasUsers, setHasUsers] = useState(true); const navigate = useNavigate(); const { login, user } = useAuth(); - React.useEffect(() => { + useEffect(() => { if (user) navigate('/', { replace: true }); }, [user, navigate]); + useEffect(() => { + api.get('/api/auth/config') + .then((res) => { + setAllowRegister(!!res.data?.allowSelfRegistration); + setHasUsers(!!res.data?.hasUsers); + if (!res.data?.hasUsers) setMode('register'); + }) + .catch(() => { /* fall through; defaults are safe */ }); + }, []); + async function handleSubmit(e: React.FormEvent) { e.preventDefault(); setError(''); @@ -79,7 +89,7 @@ export default function Login() { RefBoard

- {mode === 'login' ? 'Sign in to continue' : 'Create your account'} + {!hasUsers ? 'Create the first admin account' : mode === 'login' ? 'Sign in to continue' : 'Create your account'}

@@ -149,13 +159,13 @@ export default function Login() { - {ALLOW_REGISTER && ( + {(allowRegister || !hasUsers) && ( )}