feat: local registration is closed for good, and its on/off switch is gone

Every account comes through One Sign In; an administrator sends an
invitation link from there. The register route answers 410, the
registration-status route and the admin toggle are removed, and the
setting no longer exists in defaults, lockdown lists or seeds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fZGJNyDvERbMgS2Uc2msP
This commit is contained in:
Daniel 2026-09-15 03:18:57 +02:00
parent f94c2b9f90
commit 4c699b86ef
15 changed files with 21 additions and 201 deletions

View file

@ -69,9 +69,7 @@ var SETTINGS = {
'models.disabled': '[]', 'models.disabled': '[]',
'stt.model': 'e2e-stt', 'stt.model': 'e2e-stt',
'tts.model': 'e2e-tts', 'tts.model': 'e2e-tts',
'tts.voice': 'e2e-voice', 'tts.voice': 'e2e-voice'
// Registration open, so the auth-screen spec can see the register link.
'registration_enabled': 'true'
}; };
async function seedSettings() { async function seedSettings() {

View file

@ -41,17 +41,6 @@ test.describe('Unauthenticated auth screen', () => {
await expect(page.locator('#btn-local-login')).toBeVisible(); await expect(page.locator('#btn-local-login')).toBeVisible();
}); });
test('the register link follows the registration setting', async ({ page }) => {
// Hidden by default and shown only when registration is enabled, which the
// seed turns on. No invitation field: invitations are the SSO's.
await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 });
await expect(page.locator('#show-register')).toBeVisible();
await expect(page.locator('#register-form')).toHaveCount(1);
await page.click('#show-register');
await expect(page.locator('#reg-invite')).toHaveCount(0);
});
test('register form DOM is wired correctly if manually unhidden', async ({ page }) => { test('register form DOM is wired correctly if manually unhidden', async ({ page }) => {
await page.goto(E2E_BASE + '/'); await page.goto(E2E_BASE + '/');
await page.waitForSelector('#auth-screen', { timeout: 10000 }); await page.waitForSelector('#auth-screen', { timeout: 10000 });

View file

@ -22,16 +22,6 @@
<!-- Registration. Accounts are made at the SSO (sso.pedshub.com) from an <!-- Registration. Accounts are made at the SSO (sso.pedshub.com) from an
invitation link; the switch below only governs the local password invitation link; the switch below only governs the local password
form, which is refused anyway while sign-in is SSO-only. --> form, which is refused anyway while sign-in is SSO-only. -->
<details class="card" open>
<summary class="card-header"><h3><i class="fas fa-door-open"></i> Registration</h3></summary>
<div class="admin-card-body" style="gap:14px;">
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;">
<span id="reg-status-text" style="font-size:13px;color:var(--g600);">Loading...</span>
<button id="btn-toggle-reg" class="btn-sm btn-primary" type="button">Toggle</button>
</div>
</div>
</details>
<!-- Users. The search and refresh sit in a toolbar under the heading <!-- Users. The search and refresh sit in a toolbar under the heading
rather than inside it: a control inside <summary> toggles the card rather than inside it: a control inside <summary> toggles the card

View file

@ -55,10 +55,6 @@ function adminTabActive() {
if (e.target.closest('#btn-refresh-users')) { if (e.target.closest('#btn-refresh-users')) {
loadUsers(); loadUsers();
} }
// Toggle registration
if (e.target.closest('#btn-toggle-reg')) {
toggleRegistration();
}
}); });
function loadAdmin() { function loadAdmin() {
@ -84,39 +80,10 @@ function adminTabActive() {
el = document.getElementById('stat-api-today'); el = document.getElementById('stat-api-today');
if (el) el.textContent = stats.todayApiCalls !== undefined ? stats.todayApiCalls : '—'; if (el) el.textContent = stats.todayApiCalls !== undefined ? stats.todayApiCalls : '—';
updateRegStatus(settings.registrationEnabled !== false);
}) })
.catch(function(err) { console.error('[Admin] Settings load failed:', err); }); .catch(function(err) { console.error('[Admin] Settings load failed:', err); });
} }
function updateRegStatus(enabled) {
var text = document.getElementById('reg-status-text');
var btn = document.getElementById('btn-toggle-reg');
if (text) {
text.innerHTML = 'Registration is currently <strong style="color:' + (enabled ? 'var(--green)' : 'var(--red)') + '">' + (enabled ? '✅ Enabled' : '❌ Disabled') + '</strong>';
}
if (btn) btn.textContent = enabled ? 'Disable Registration' : 'Enable Registration';
window._adminRegEnabled = enabled;
}
function toggleRegistration() {
var newVal = !window._adminRegEnabled;
fetch('/api/admin/settings/registration', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({ enabled: newVal })
})
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.success) {
updateRegStatus(newVal);
showToast('Registration ' + (newVal ? 'enabled' : 'disabled'), 'success');
} else {
showToast(data.error || 'Failed', 'error');
}
})
.catch(function() { showToast('Request failed', 'error'); });
}
// ---- USERS ---- // ---- USERS ----
@ -1919,7 +1886,7 @@ initImageSettings();
// the buttons that only read. // the buttons that only read.
function lockdownFields(panel, state) { function lockdownFields(panel, state) {
var editable = ['cms-ann', 'announcement', var editable = ['cms-ann', 'announcement',
'admin-users-search', 'registration']; 'admin-users-search'];
panel.querySelectorAll('input, select, textarea, button').forEach(function(el) { panel.querySelectorAll('input, select, textarea, button').forEach(function(el) {
var id = el.id || ''; var id = el.id || '';
if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return; if (editable.some(function(prefix) { return id.indexOf(prefix) === 0; })) return;

View file

@ -1231,16 +1231,7 @@ document.addEventListener('DOMContentLoaded', function() {
document.body.appendChild(modal); document.body.appendChild(modal);
} }
// Check registration status — link is hidden by default, shown only when enabled // The register link stays hidden: accounts are created through One Sign In.
fetch('/api/auth/registration-status')
.then(function(r) { return r.json(); })
.then(function(data) {
if (data.registrationEnabled) {
var link = document.getElementById('show-register');
if (link) link.style.display = '';
}
})
.catch(function() {});
console.log('[Auth] ✅ Module ready'); console.log('[Auth] ✅ Module ready');
}); });

View file

@ -11,7 +11,7 @@ if (!window.__fetchAuthIntercepted) {
'/api/auth/login', '/api/auth/register', '/api/auth/logout', '/api/auth/login', '/api/auth/register', '/api/auth/logout',
'/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/forgot-password', '/api/auth/reset-password',
'/api/auth/verify-email', '/api/auth/resend-verification', '/api/auth/verify-email', '/api/auth/resend-verification',
'/api/auth/oidc', '/api/auth/oidc-status', '/api/auth/registration-status' '/api/auth/oidc', '/api/auth/oidc-status'
]); ]);
boundary.logoutRequest = function(headers) { boundary.logoutRequest = function(headers) {
return rawFetch('/api/auth/logout', { return rawFetch('/api/auth/logout', {

View file

@ -325,8 +325,6 @@ async function initDatabase() {
// Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes) // Seed all default config values (ON CONFLICT DO NOTHING — never overwrites admin changes)
var defaults = [ var defaults = [
// Core
['registration_enabled', 'true'],
// Announcements // Announcements
['announcement.enabled', 'false'], ['announcement.enabled', 'false'],
['announcement.text', ''], ['announcement.text', ''],

View file

@ -174,37 +174,18 @@ router.post('/users/:id/reset-password', require('../utils/policy').requireLocal
} catch (err) { res.status(500).json({ error: 'Request failed' }); } } catch (err) { res.status(500).json({ error: 'Request failed' }); }
}); });
// ============================================================
// TOGGLE REGISTRATION
// ============================================================
router.post('/settings/registration', async function(req, res) {
try {
var { enabled } = req.body;
var value = enabled ? 'true' : 'false';
await db.setSetting('registration_enabled', value);
logger.audit(req.user.id, 'admin_toggle_registration', 'Registration ' + (enabled ? 'enabled' : 'disabled'), req, { category: 'admin' });
res.json({ success: true, registrationEnabled: enabled, message: 'Registration ' + (enabled ? 'enabled' : 'disabled') });
} catch (err) { res.status(500).json({ error: 'Request failed' }); }
});
// ============================================================ // ============================================================
// GET APP SETTINGS // GET APP SETTINGS
// ============================================================ // ============================================================
router.get('/settings', async function(req, res) { router.get('/settings', async function(req, res) {
try { try {
var regEnabled = await db.getSetting('registration_enabled');
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []); var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []); var apiCount = await db.get('SELECT COUNT(*) as count FROM api_log', []);
var todayApiCount = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []); var todayApiCount = await db.get("SELECT COUNT(*) as count FROM api_log WHERE timestamp > NOW() - INTERVAL '1 day'", []);
res.json({ res.json({
success: true, success: true,
settings: { settings: {},
registrationEnabled: regEnabled !== 'false'
},
stats: { stats: {
totalUsers: userCount ? parseInt(userCount.count) : 0, totalUsers: userCount ? parseInt(userCount.count) : 0,
totalApiCalls: apiCount ? parseInt(apiCount.count) : 0, totalApiCalls: apiCount ? parseInt(apiCount.count) : 0,

View file

@ -246,7 +246,6 @@ router.post('/config/prompts/:key/restore', function(req, res) {
router.post('/config/reset-defaults', async function(req, res) { router.post('/config/reset-defaults', async function(req, res) {
try { try {
var defaults = [ var defaults = [
['registration_enabled', 'true'],
['announcement.enabled', 'false'], ['announcement.enabled', 'false'],
['announcement.text', ''], ['announcement.text', ''],
['announcement.type', 'info'], ['announcement.type', 'info'],
@ -973,7 +972,7 @@ router.put('/config/:key(*)', async function(req, res) {
} }
// Security: only allow known key prefixes // Security: only allow known key prefixes
var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'registration_enabled', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.', 'nextcloud.']; var allowed = ['announcement.', 'feature.', 'email.', 'prompt.', 'site.', 'smtp.', 'models.', 'tts.', 'stt.', 'clinical_assistant.', 'my_resources.', 'nextcloud.'];
var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); }); var isAllowed = allowed.some(function(p) { return key === p || key.startsWith(p); });
if (!isAllowed) { if (!isAllowed) {
return res.status(400).json({ error: 'Unknown config key' }); return res.status(400).json({ error: 'Unknown config key' });

View file

@ -173,89 +173,11 @@ async function sendEmail(to, subject, html) {
} }
// ============================================================ // ============================================================
// REGISTER (checks if registration is enabled) // REGISTER — closed. Every account is created through One Sign In
// (authentik); an administrator sends an invitation link from there.
// ============================================================ // ============================================================
router.post('/register', requireLocalAuth, async (req, res) => { router.post('/register', function(req, res) {
try { res.status(410).json({ error: 'Accounts are created through One Sign In. Ask an administrator for an invitation link.' });
var regEnabled = await db.getSetting('registration_enabled');
if (regEnabled === 'false') {
return res.status(403).json({ error: 'Registration is currently disabled. Contact an administrator.' });
}
var { email, password, name, turnstileToken } = req.body;
if (!email || !password || !name) return res.status(400).json({ error: 'All fields required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be 8+ characters' });
// Cloudflare Turnstile verification
if (process.env.TURNSTILE_SECRET_KEY) {
if (!turnstileToken) return res.status(400).json({ error: 'Please complete the verification challenge' });
var turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ secret: process.env.TURNSTILE_SECRET_KEY, response: turnstileToken, remoteip: req.ip })
});
var turnstileData = await turnstileRes.json();
if (!turnstileData.success) {
console.error('[Auth] Turnstile verification failed:', turnstileData['error-codes']);
return res.status(400).json({ error: 'Bot verification failed. Please try again.' });
}
}
var existing = await db.get('SELECT id FROM users WHERE email = ?', [email.toLowerCase()]);
if (existing) return res.status(400).json({ error: 'Email already registered' });
var hash = await passwords.hash(password);
var verifyToken = crypto.randomBytes(32).toString('hex');
var verifyExpires = Date.now() + 24 * 60 * 60 * 1000;
var userCount = await db.get('SELECT COUNT(*) as count FROM users', []);
var role = (userCount && parseInt(userCount.count) === 0) ? 'admin' : 'user';
var result = await db.run(
'INSERT INTO users (email, password, name, role, verify_token, verify_expires, email_verified) VALUES (?, ?, ?, ?, ?, ?, false)',
[email.toLowerCase(), hash, name, role, verifyToken, verifyExpires]
);
var userId = result.lastInsertRowid;
var verifyUrl = safeAppUrl() + '/api/auth/verify-email?token=' + verifyToken;
var verifySubject = await db.getSetting('email.verify.subject') || 'Verify your Pediatric AI Scribe account';
var verifyBody = await db.getSetting('email.verify.body') || 'Great to have you on Pediatric AI Scribe. Please verify your email address by clicking the button below.';
await sendEmail(email, verifySubject, emailWrapper(
`<p style="margin:0 0 8px;font-size:20px;font-weight:600;">Welcome aboard, ${escHtml(name)}!</p>
<p style="color:#4b5563;margin:12px 0 20px;line-height:1.6;font-size:14px;">${escHtml(verifyBody).replace(/\n/g, '<br>')}</p>
${btnHtml(verifyUrl, 'Verify My Email')}
${linkFallback(verifyUrl)}
<p style="color:#9ca3af;font-size:11px;margin:16px 0 0;">This link expires in 24 hours.</p>`
));
await db.run('INSERT INTO audit_log (user_id, action, ip_address, details) VALUES (?, ?, ?, ?)',
[userId, 'register', req.ip, role === 'admin' ? 'First user — auto admin' : 'standard user']);
logger.audit(userId, 'register', role === 'admin' ? 'First user — auto admin' : 'standard user', req, { category: 'auth' });
notifyNewRegistration(email, name);
var smtpHost = await db.getSetting('smtp.host').catch(function() { return null; }) || process.env.SMTP_HOST;
if (!smtpHost) {
await db.run('UPDATE users SET email_verified = true, verify_token = NULL WHERE id = ?', [userId]);
var token = signAuthToken(userId, req);
var regSessionId = generateSessionId();
await db.run('INSERT INTO user_sessions (id, user_id, token_hash, ip_address, user_agent, device_label) VALUES (?, ?, ?, ?, ?, ?)',
[regSessionId, userId, hashToken(token), req.ip, req.headers['user-agent'] || '', parseUserAgent(req.headers['user-agent'])]);
setAuthCookie(res, token);
return res.json({
success: true, token: token, sessionId: regSessionId,
user: { id: userId, email: email.toLowerCase(), name: name, role: role, email_verified: true },
message: role === 'admin' ? 'Account created as ADMIN (first user). Auto-verified.' : 'Account created (auto-verified).'
});
}
res.json({ success: true, needsVerification: true, message: 'Check your email for verification link.' + (role === 'admin' ? ' You are the first user and have admin privileges.' : '') });
} catch (err) {
console.error('[Auth] Register error:', err.message);
res.status(500).json({ error: 'Registration failed. Please try again.' });
}
}); });
// ============================================================ // ============================================================
@ -701,14 +623,6 @@ router.get('/me', authMiddleware, async (req, res) => {
} catch (err) { console.error('[Auth] Me error:', err.message); res.status(500).json({ error: 'Failed to load user' }); } } catch (err) { console.error('[Auth] Me error:', err.message); res.status(500).json({ error: 'Failed to load user' }); }
}); });
// Check if registration is enabled (public endpoint)
router.get('/registration-status', async (req, res) => {
try {
var enabled = await db.getSetting('registration_enabled');
res.json({ registrationEnabled: enabled !== 'false' && !await isSSOOnly() });
} catch (err) { res.json({ registrationEnabled: false }); }
});
// Expose helpers for adminConfig test-email and email template loading // Expose helpers for adminConfig test-email and email template loading
module.exports = router; module.exports = router;
module.exports.__sendEmail = sendEmail; module.exports.__sendEmail = sendEmail;

View file

@ -28,7 +28,6 @@ var LOCKED_PREFIXES = Object.freeze([
// Day-to-day operation stays with ordinary admins. // Day-to-day operation stays with ordinary admins.
var EDITABLE_WHEN_LOCKED = Object.freeze([ var EDITABLE_WHEN_LOCKED = Object.freeze([
'announcement.', 'announcement.',
'registration_enabled',
'feature.', 'feature.',
'site.' 'site.'
]); ]);

View file

@ -56,13 +56,8 @@ var operations = {
public: true public: true
}, },
'POST /api/auth/register': { 'POST /api/auth/register': {
summary: 'Create a local password account', summary: 'Closed',
description: 'Refused while sign-in is SSO-only; accounts are then created at the SSO from an invitation link.', description: 'Always 410. Accounts are created through One Sign In from an invitation link an administrator sends.',
public: true
},
'GET /api/auth/registration-status': {
summary: 'Whether local registration is open',
description: 'Read by the sign-in screen to decide whether to offer the register link.',
public: true public: true
}, },
'POST /api/auth/logout': { summary: 'End this session' }, 'POST /api/auth/logout': { summary: 'End this session' },

View file

@ -166,7 +166,7 @@ test('admin lockdown refuses configuration writes at the server', () => {
'tts.voice', 'stt.model', 'smtp.host', 'email.verify.subject']) { 'tts.voice', 'stt.model', 'smtp.host', 'email.verify.subject']) {
assert.equal(lockdown.isLocked(key, on), true, key + ' is locked'); assert.equal(lockdown.isLocked(key, on), true, key + ' is locked');
} }
for (const key of ['announcement.text', 'registration_enabled', 'feature.memories', 'site.name']) { for (const key of ['announcement.text', 'feature.memories', 'site.name']) {
assert.equal(lockdown.isLocked(key, on), false, key + ' stays editable'); assert.equal(lockdown.isLocked(key, on), false, key + ' stays editable');
} }

View file

@ -106,10 +106,10 @@ test('metadata is keyed exactly as routes are mounted', () => {
test('a public operation says so, and is the only kind that does', () => { test('a public operation says so, and is the only kind that does', () => {
// Guessing this from middleware gets it wrong in the direction that hides a // Guessing this from middleware gets it wrong in the direction that hides a
// hole, so it is stated per route. Registration status must be public — the // hole, so it is stated per route. Sign-in must be public — the sign-in
// sign-in screen reads it before anyone has a session. // screen calls it before anyone has a session.
const meta = require('../src/utils/openapiRoutes'); const meta = require('../src/utils/openapiRoutes');
assert.equal(meta.operations['GET /api/auth/registration-status'].public, true); assert.equal(meta.operations['POST /api/auth/login'].public, true);
assert.equal(meta.operations['GET /api/health'].public, true); assert.equal(meta.operations['GET /api/health'].public, true);
assert.equal(meta.operations['POST /api/auth/logout'].public, undefined); assert.equal(meta.operations['POST /api/auth/logout'].public, undefined);
}); });

View file

@ -244,13 +244,12 @@ test('OIDC refuses unsafe linking, disabled/mismatched identities, and session f
test('active SSO-only policy denies local login/registration/credential creation, but disabled OIDC cannot lock out local auth', async t => { test('active SSO-only policy denies local login/registration/credential creation, but disabled OIDC cannot lock out local auth', async t => {
const f = fixture(); const request = await f.serve(t, true); const f = fixture(); const request = await f.serve(t, true);
f.state.settings['oidc.disable_local_auth'] = 'true'; f.state.settings['oidc.disable_local_auth'] = 'true';
for (const route of ['/api/auth/login', '/api/auth/register', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) { for (const route of ['/api/auth/login', '/api/auth/forgot-password', '/api/auth/reset-password', '/api/auth/change-password', '/api/auth/setup-2fa', '/api/auth/verify-2fa', '/api/auth/2fa/backup-codes', '/api/admin/users/7/reset-password']) {
const response = await request(route, { method: 'POST', body: {}, authenticated: true }); const response = await request(route, { method: 'POST', body: {}, authenticated: true });
assert.equal(response.status, 403, route); assert.equal(response.data.code, 'sso_only'); assert.equal(authCookies(response).length, 0); assert.equal(response.status, 403, route); assert.equal(response.data.code, 'sso_only'); assert.equal(authCookies(response).length, 0);
} }
assert.equal(f.state.writes.length, 0); assert.equal(f.state.writes.length, 0);
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true); assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, true);
assert.equal((await request('/api/auth/registration-status')).data.registrationEnabled, false);
assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false); assert.equal((await request('/api/auth/me', { authenticated: true })).data.user.canLocalAuth, false);
f.state.settings['oidc.enabled'] = 'false'; f.state.settings['oidc.enabled'] = 'false';
assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, false); assert.equal((await request('/api/auth/oidc-status')).data.disableLocalAuth, false);
@ -261,14 +260,14 @@ test('active SSO-only policy denies local login/registration/credential creation
assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0); assert.equal(unavailable.status, 503); assert.equal(authCookies(unavailable).length, 0);
}); });
test('local login and auto-verified registration never issue success/cookie on session insert failure', async t => { test('local login never issues success/cookie on session insert failure, and registration is closed', async t => {
const f = fixture(); const request = await f.serve(t); f.state.sessionError = true; const f = fixture(); const request = await f.serve(t); f.state.sessionError = true;
const login = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } }); const login = await request('/api/auth/login', { method: 'POST', body: { email: f.state.user.email, password: 'synthetic-password' } });
assert.equal(login.status, 500); assert.equal(authCookies(login).length, 0); assert.equal(login.data.success, undefined); assert.equal(login.status, 500); assert.equal(authCookies(login).length, 0); assert.equal(login.data.success, undefined);
f.state.user = null; assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 1);
// Local registration is closed for good: it answers 410 before touching anything.
const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } }); const registration = await request('/api/auth/register', { method: 'POST', body: { email: 'new@example.test', password: 'synthetic-password', name: 'Synthetic' } });
assert.equal(registration.status, 500); assert.equal(authCookies(registration).length, 0); assert.equal(registration.data.success, undefined); assert.equal(registration.status, 410); assert.equal(authCookies(registration).length, 0);
assert.equal(f.state.writes.filter(w => w.sql.includes('INSERT INTO user_sessions')).length, 2);
}); });
test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => { test('final model allowlist is enforced for streaming/nonstream, invalid settings/outage, defaults and fallback', async () => {