mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -06:00
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:
parent
6e31770cee
commit
54549420e7
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
31
frontend/js/views/no-workspace.js
Normal file
31
frontend/js/views/no-workspace.js
Normal 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() {}
|
||||
|
|
@ -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()),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
Loading…
Reference in a new issue