feat(signup): optional org-on-create for self-service signups (#12)

MSP-style deployments want self-service signups created WITHOUT a personal
org, so an admin/operator can assign them into an existing customer org
afterward.

- config.autoCreateOrgOnSignup (AUTO_CREATE_ORG_ON_SIGNUP env), default
  true - single-tenant and the hosted self-service flow are unchanged.
- ensureDefaultOrgForUser gains { allowCreate }: an existing membership is
  always returned (idempotent); the MINT path is gated. allowCreate=false +
  no membership -> returns null (user created org-less).
- register accepts a per-request createOrg flag overriding the deployment
  default; the first-ever user is always given an org (never headless).
  login / Google / Microsoft pass allowCreate from the global config, so an
  org-less user is not silently given an org on next sign-in.

Edge case: a non-platform user with zero workspaces now lands on a "no
workspaces yet" empty state (new no-workspace view) instead of being bounced
into onboarding (whose pairing step needs a workspace). route() redirects
them there, and refreshCurrentUser() redirects once /me reveals zero
accessible_workspaces (covers the first-load race). The workspace switcher
already rendered an empty placeholder and resource routes already return []
for a null workspace, so nothing crashes in between.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
ScreenTinker 2026-06-05 11:16:27 -05:00
parent 6e31770cee
commit 54549420e7
5 changed files with 106 additions and 6 deletions

View file

@ -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';

View file

@ -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}",

View file

@ -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 = `
<div style="display:flex;align-items:center;justify-content:center;min-height:100vh;padding:16px">
<div style="width:440px;max-width:100%;text-align:center">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="var(--text-muted)" stroke-width="1.6" style="margin:0 auto 16px">
<rect x="3" y="4" width="18" height="14" rx="2"/>
<path d="M3 9h18"/>
</svg>
<h1 style="font-size:20px;font-weight:700;margin-bottom:8px">${t('noworkspace.title')}</h1>
<p style="color:var(--text-secondary);font-size:14px;line-height:1.6;margin-bottom:24px">${t('noworkspace.body')}</p>
<button class="btn btn-secondary" id="noWsSignOut" style="padding:8px 16px">${t('noworkspace.sign_out')}</button>
</div>
</div>
`;
container.querySelector('#noWsSignOut').addEventListener('click', () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
window.location.hash = '#/login';
window.location.reload();
});
}
export function cleanup() {}

View file

@ -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()),
};

View file

@ -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 });