diff --git a/frontend/js/app.js b/frontend/js/app.js
index dc31b78..12e2152 100644
--- a/frontend/js/app.js
+++ b/frontend/js/app.js
@@ -21,6 +21,7 @@ import * as designer from './views/designer.js';
import * as playlists from './views/playlists.js';
import * as workspaceMembers from './views/workspace-members.js';
import * as forcePasswordChange from './views/force-password-change.js';
+import * as noWorkspace from './views/no-workspace.js';
import { applyBranding } from './branding.js';
import { t } from './i18n.js';
import { isPlatformAdmin } from './utils.js';
@@ -211,6 +212,18 @@ function getCurrentUser() {
} catch { return null; }
}
+// #12: true when a signed-in user provably has zero accessible workspaces and
+// no platform-level reach. Requires accessible_workspaces to be present (only
+// /me populates it) - undefined means "not loaded yet", so we DON'T trigger and
+// fall through to the normal (workspace-empty-safe) views until /me resolves.
+function hasNoAccessibleWorkspace(u) {
+ return !!u
+ && Array.isArray(u.accessible_workspaces)
+ && u.accessible_workspaces.length === 0
+ && !u.current_workspace_id
+ && !isPlatformAdmin(u);
+}
+
// Refresh the cached user from the server. The server reads plan_id fresh
// from the DB on every request, but the frontend only wrote `user` into
// localStorage at login — so plan/role changes made by an admin weren't
@@ -227,6 +240,16 @@ async function refreshCurrentUser() {
// the dropdown in sync if a workspace was added/removed in another tab.
renderWorkspaceSwitcher(fresh);
window.dispatchEvent(new CustomEvent('user-refreshed', { detail: fresh }));
+ // #12: /me is the first place accessible_workspaces is known. If it resolves
+ // to zero (org-less user), send them to the empty state now - on a fresh
+ // load route() may have already rendered the dashboard before /me returned.
+ // Guard against the login / change-password / already-there screens to avoid
+ // a redirect loop.
+ const hash = window.location.hash || '#/';
+ if (hasNoAccessibleWorkspace(fresh)
+ && hash !== '#/no-workspace' && hash !== '#/login' && hash !== '#/change-password') {
+ window.location.hash = '#/no-workspace';
+ }
} catch {}
}
@@ -304,6 +327,29 @@ function route() {
}
}
+ // #12: a signed-in user with zero accessible workspaces (org-less self-signup
+ // on an AUTO_CREATE_ORG_ON_SIGNUP=false deployment) lands on a "no workspaces
+ // yet" empty state instead of being bounced into onboarding (whose pairing
+ // step needs a workspace). Only fires once /me has populated
+ // accessible_workspaces; until then the workspace-empty-safe dashboard shows.
+ if (isAuthenticated()) {
+ const u = getCurrentUser();
+ if (hasNoAccessibleWorkspace(u) && hash !== '#/no-workspace') {
+ window.location.hash = '#/no-workspace';
+ return;
+ }
+ if (hash === '#/no-workspace') {
+ if (!hasNoAccessibleWorkspace(u)) { window.location.hash = '#/'; return; }
+ sidebar.style.display = 'none';
+ app.style.marginLeft = '0';
+ const mb = document.getElementById('mobileMenuBtn');
+ if (mb) mb.style.display = 'none';
+ currentView = noWorkspace;
+ noWorkspace.render(app);
+ return;
+ }
+ }
+
// Onboarding for new users
if (hash === '#/onboarding' && isAuthenticated()) {
sidebar.style.display = 'none';
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index 0d2b5ca..c1c1223 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -1217,6 +1217,11 @@ export default {
'forcepw.error_mismatch': "Passwords don't match.",
'forcepw.error_generic': 'Could not update password. Try again.',
+ // No-workspace empty state (#12): shown to an org-less signed-in user.
+ 'noworkspace.title': 'No workspaces yet',
+ 'noworkspace.body': "Your account isn't part of any workspace yet. Ask your administrator to add you to one, then sign in again.",
+ 'noworkspace.sign_out': 'Sign out',
+
// Accept-invite flow (Slice 2C). Toasts that fire post-accept on the
// dashboard. Error variants share one helper in app.js's mapAcceptError().
'accept.success': "You've joined {name}",
diff --git a/frontend/js/views/no-workspace.js b/frontend/js/views/no-workspace.js
new file mode 100644
index 0000000..29076fb
--- /dev/null
+++ b/frontend/js/views/no-workspace.js
@@ -0,0 +1,31 @@
+// #12: empty state for a signed-in user who belongs to zero workspaces. Happens
+// on deployments with AUTO_CREATE_ORG_ON_SIGNUP=false, where a self-service
+// signup is created org-less and an admin/operator assigns them to a workspace
+// afterward. Without this, such a user would be bounced into onboarding (whose
+// device-pairing step needs a workspace) - a broken flow. Here they get a clear
+// "ask your admin" message instead.
+import { t } from '../i18n.js';
+
+export function render(container) {
+ container.innerHTML = `
+
+
+
+
${t('noworkspace.title')}
+
${t('noworkspace.body')}
+
+
+
+ `;
+ container.querySelector('#noWsSignOut').addEventListener('click', () => {
+ localStorage.removeItem('token');
+ localStorage.removeItem('user');
+ window.location.hash = '#/login';
+ window.location.reload();
+ });
+}
+
+export function cleanup() {}
diff --git a/server/config.js b/server/config.js
index 02b5d82..fa4d505 100644
--- a/server/config.js
+++ b/server/config.js
@@ -69,4 +69,10 @@ module.exports = {
// Redirect / -> /app instead of serving the marketing landing page.
// For self-hosted internal deployments that don't want the public homepage.
disableHomepage: ['true', '1'].includes(String(process.env.DISABLE_HOMEPAGE || '').toLowerCase()),
+ // Issue #12: auto-create a personal org + Default workspace for self-service
+ // signups (public register + OAuth). Defaults TRUE so single-tenant and the
+ // hosted self-service flow are unaffected; set AUTO_CREATE_ORG_ON_SIGNUP=false
+ // on MSP-style deployments where an admin/operator assigns users to existing
+ // orgs after signup instead.
+ autoCreateOrgOnSignup: !['false', '0'].includes(String(process.env.AUTO_CREATE_ORG_ON_SIGNUP || '').toLowerCase()),
};
diff --git a/server/routes/auth.js b/server/routes/auth.js
index ce7ed6b..ff64cda 100644
--- a/server/routes/auth.js
+++ b/server/routes/auth.js
@@ -15,7 +15,11 @@ const config = require('../config');
// workspace_id to embed in the JWT. Idempotent: if the user already has
// memberships (e.g. migrated from Phase 1), returns the first one without
// creating anything.
-function ensureDefaultOrgForUser(user) {
+// #12: allowCreate gates the MINT path only. An existing membership is always
+// returned (idempotent). When allowCreate is false and the user has no
+// membership, returns null - the caller is created org-less and an admin /
+// operator assigns them to a workspace afterward.
+function ensureDefaultOrgForUser(user, { allowCreate = true } = {}) {
const existing = db.prepare(`
SELECT w.id FROM workspaces w
JOIN workspace_members wm ON wm.workspace_id = w.id
@@ -23,6 +27,7 @@ function ensureDefaultOrgForUser(user) {
ORDER BY wm.joined_at ASC LIMIT 1
`).get(user.id);
if (existing) return existing.id;
+ if (!allowCreate) return null;
// No memberships -> mint a fresh org and Default workspace owned by user.
const orgId = uuidv4();
@@ -85,7 +90,7 @@ 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;
+ const { email, password, name, createOrg } = 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' });
@@ -109,7 +114,14 @@ router.post('/register', (req, res) => {
`).run(id, email.toLowerCase(), name || email.split('@')[0], passwordHash, role, plan, trialStarted, trialStarted ? 'pro' : null);
const user = db.prepare('SELECT id, email, name, role, auth_provider, avatar_url, plan_id, stripe_customer_id, stripe_subscription_id, subscription_status, subscription_ends FROM users WHERE id = ?').get(id);
- const workspaceId = ensureDefaultOrgForUser(user);
+ // #12: org-on-create. Per-request createOrg overrides the deployment default
+ // (config.autoCreateOrgOnSignup). The first user is always given an org so a
+ // fresh install is never left headless. When neither applies, the user is
+ // created org-less and lands on the "no workspaces yet" state until an admin
+ // assigns them.
+ const createOrgForUser = isFirstUser
+ || (createOrg !== undefined ? !!createOrg : config.autoCreateOrgOnSignup);
+ const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: createOrgForUser });
const token = generateToken(user, workspaceId);
res.status(201).json({ token, user, current_workspace_id: workspaceId });
@@ -135,7 +147,7 @@ router.post('/login', (req, res) => {
}
logSuccessfulLogin(user.id, email, getClientIp(req));
- const workspaceId = ensureDefaultOrgForUser(user);
+ const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });
@@ -187,7 +199,7 @@ router.post('/google', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
- const workspaceId = ensureDefaultOrgForUser(user);
+ const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });
@@ -268,7 +280,7 @@ router.post('/microsoft', async (req, res) => {
user = db.prepare('SELECT * FROM users WHERE id = ?').get(user.id);
}
- const workspaceId = ensureDefaultOrgForUser(user);
+ const workspaceId = ensureDefaultOrgForUser(user, { allowCreate: config.autoCreateOrgOnSignup });
const token = generateToken(user, workspaceId);
const { password_hash, ...safeUser } = user;
res.json({ token, user: safeUser, current_workspace_id: workspaceId });