diff --git a/README.md b/README.md
index a032e1f..f8d85fd 100644
--- a/README.md
+++ b/README.md
@@ -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` |
diff --git a/frontend/js/views/login.js b/frontend/js/views/login.js
index b8cc660..de67329 100644
--- a/frontend/js/views/login.js
+++ b/frontend/js/views/login.js
@@ -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 = `
@@ -26,7 +28,7 @@ export async function render(container) {
${isSetup ? 'Create your admin account to get started' : 'Sign in to manage your displays'}
- ${isSetup ? '' : '
New accounts get a 14-day free Pro trial
'}
+ ${!isSetup && canRegister ? '
New accounts get a 14-day free Pro trial
' : ''}
@@ -49,7 +51,7 @@ export async function render(container) {
- ${!isSetup ? `
+ ${!isSetup && canRegister ? `
diff --git a/server/config.js b/server/config.js
index bb8e698..34a1c9e 100644
--- a/server/config.js
+++ b/server/config.js
@@ -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()),
};
diff --git a/server/routes/auth.js b/server/routes/auth.js
index cc54753..0731827 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -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,
});
});