Add DISABLE_REGISTRATION env var to block public sign-ups

When DISABLE_REGISTRATION=true (or 1), POST /api/auth/register returns
403 with a clear error. OAuth endpoints (/google, /microsoft) also
refuse to auto-create new accounts — existing OAuth users can still
sign in. First-user setup (empty users table) is always allowed so a
fresh install can still be initialized.

GET /api/auth/config now returns registration_enabled so the login
view can hide the "Create Account" button and the trial banner when
registration is off. Absence of the flag is treated as enabled for
back-compat with older servers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-04-22 19:35:32 -05:00
parent ea86d70475
commit 4392bb460a
4 changed files with 26 additions and 2 deletions

View file

@ -59,6 +59,7 @@ The server starts on port 3001 (HTTP). If SSL certificates are present in `serve
| `PORT` | HTTP port | `3001` |
| `HTTPS_PORT` | HTTPS port (used when SSL certs are present) | `3443` |
| `SELF_HOSTED` | First user gets all features unlocked | `false` |
| `DISABLE_REGISTRATION` | Block new account creation (including OAuth auto-signup). First-user setup on an empty DB is still allowed. | `false` |
| `APP_URL` | Your public URL (used for Stripe callbacks) | _(none)_ |
| `JWT_SECRET` | JWT signing key (auto-generated if not set) | _(auto)_ |
| `SSL_CERT` | Path to SSL certificate | `server/certs/cert.pem` |

View file

@ -12,6 +12,8 @@ async function loadAuthConfig() {
export async function render(container) {
const config = await loadAuthConfig();
const isSetup = config.needsSetup;
// registration_enabled may be absent on older servers — treat as enabled for back-compat
const canRegister = config.registration_enabled !== false;
container.innerHTML = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
@ -26,7 +28,7 @@ export async function render(container) {
<p style="color:var(--text-secondary);font-size:13px;margin-top:4px">
${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'}
</p>
${isSetup ? '' : '<p style="color:var(--warning);font-size:12px;margin-top:8px">New accounts get a 14-day free Pro trial</p>'}
${!isSetup && canRegister ? '<p style="color:var(--warning);font-size:12px;margin-top:8px">New accounts get a 14-day free Pro trial</p>' : ''}
</div>
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px">
@ -49,7 +51,7 @@ export async function render(container) {
<button class="btn btn-primary" id="loginBtn" style="width:100%;justify-content:center;padding:10px">
${isSetup ? 'Create Admin Account' : 'Sign In'}
</button>
${!isSetup ? `
${!isSetup && canRegister ? `
<button class="btn btn-secondary" id="showRegisterBtn" style="width:100%;justify-content:center;padding:10px;margin-top:8px">
Create Account
</button>

View file

@ -39,4 +39,7 @@ module.exports = {
emailWebhookUrl: process.env.EMAIL_WEBHOOK_URL || '',
// Self-hosted mode: if true, first user gets enterprise plan and no billing
selfHosted: process.env.SELF_HOSTED === 'true',
// Disable public registration (OAuth auto-signup is also blocked when set).
// First-user setup is still allowed so a fresh install can be initialized.
disableRegistration: ['true', '1'].includes(String(process.env.DISABLE_REGISTRATION || '').toLowerCase()),
};

View file

@ -25,8 +25,19 @@ function logSuccessfulLogin(userId, email, ip) {
// ==================== Local Auth ====================
// Returns true if new account creation is allowed at this moment.
// First-user setup (empty DB) is always allowed so a fresh install can be initialized.
function canRegister() {
if (!config.disableRegistration) return true;
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
return userCount === 0;
}
// Register
router.post('/register', (req, res) => {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const { email, password, name } = req.body;
if (!email || !password) return res.status(400).json({ error: 'Email and password required' });
if (password.length < 8) return res.status(400).json({ error: 'Password must be at least 8 characters' });
@ -94,6 +105,9 @@ router.post('/google', async (req, res) => {
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email.toLowerCase());
if (!user) {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
@ -169,6 +183,9 @@ router.post('/microsoft', async (req, res) => {
let user = db.prepare('SELECT * FROM users WHERE email = ?').get(email);
if (!user) {
if (!canRegister()) {
return res.status(403).json({ error: 'Public registration is disabled. Contact your administrator.' });
}
const id = uuidv4();
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get().count;
const role = userCount === 0 ? 'superadmin' : 'user';
@ -297,6 +314,7 @@ router.get('/config', (req, res) => {
microsoftTenantId: config.microsoftTenantId,
localEnabled: true,
needsSetup: userCount === 0,
registration_enabled: !config.disableRegistration || userCount === 0,
});
});