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