mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-21 13:42:38 -06:00
feat(admin): Create Organization for platform admins (#35)
MSPs onboarding customers as separate orgs had no way to create one with AUTO_CREATE_ORG_ON_SIGNUP=false (the only path was signup auto-org). Add a platform-admin 'Create organization' action. POST /api/admin/orgs (requirePlatformAdmin) creates the org + its first 'Default' workspace. organizations.owner_user_id is NOT NULL, so an org can't be ownerless; the creating admin becomes org_owner + workspace_admin (mirrors the signup bootstrap in routes/auth.js) - which also surfaces the org in their switcher. Customer users are then added via the existing Add User / manage-memberships flow. UI: 'Create organization' button + single-field modal in the Admin area (gated). Tests: create (201 + memberships + audit), empty-name 400, non-admin/operator 403.
This commit is contained in:
parent
69b46647c5
commit
ae595a208d
|
|
@ -177,6 +177,7 @@ export const api = {
|
||||||
// Admin-provisioned user creation (#10). data: { email, name, password,
|
// Admin-provisioned user creation (#10). data: { email, name, password,
|
||||||
// workspaceId, role, mustChangePassword }
|
// workspaceId, role, mustChangePassword }
|
||||||
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
adminCreateUser: (data) => request('/admin/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
adminCreateOrg: (name) => request('/admin/orgs', { method: 'POST', body: JSON.stringify({ name }) }),
|
||||||
|
|
||||||
// Instance-level default branding (#15, platform admin).
|
// Instance-level default branding (#15, platform admin).
|
||||||
adminGetBranding: () => request('/admin/branding'),
|
adminGetBranding: () => request('/admin/branding'),
|
||||||
|
|
|
||||||
72
frontend/js/components/admin-create-org-modal.js
Normal file
72
frontend/js/components/admin-create-org-modal.js
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
import { api } from '../api.js';
|
||||||
|
import { t } from '../i18n.js';
|
||||||
|
|
||||||
|
// Create-Organization modal (#35). Platform-admin only (the page is gated; the
|
||||||
|
// endpoint re-checks). Creates a named org + its first "Default" workspace, owned
|
||||||
|
// by the creating admin (organizations.owner_user_id is NOT NULL). On success the
|
||||||
|
// org appears in the switcher, so we reload to refresh it — matching the
|
||||||
|
// workspace rename/switch flow. opts.onSuccess(result) fires before reload.
|
||||||
|
export function openCreateOrgModal(opts = {}) {
|
||||||
|
const { onSuccess } = opts;
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="modal">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3>${t('admin.create_org.title')}</h3>
|
||||||
|
<button class="btn-icon" type="button" data-org-close aria-label="Close">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="createOrgName">${t('admin.create_org.name')}</label>
|
||||||
|
<input id="createOrgName" type="text" class="input" maxlength="120" placeholder="${t('admin.create_org.placeholder')}" style="width:100%">
|
||||||
|
<div style="color:var(--text-muted);font-size:11px;margin-top:4px">${t('admin.create_org.hint')}</div>
|
||||||
|
</div>
|
||||||
|
<div id="createOrgError" style="display:none;color:var(--danger);font-size:13px;margin-top:8px"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" type="button" data-org-close>${t('common.cancel')}</button>
|
||||||
|
<button class="btn btn-primary" type="button" id="createOrgSave">${t('admin.create_org.submit')}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
|
||||||
|
const nameInput = overlay.querySelector('#createOrgName');
|
||||||
|
const errorEl = overlay.querySelector('#createOrgError');
|
||||||
|
const saveBtn = overlay.querySelector('#createOrgSave');
|
||||||
|
nameInput.focus();
|
||||||
|
|
||||||
|
function close() { overlay.remove(); document.removeEventListener('keydown', onKey); }
|
||||||
|
function onKey(e) {
|
||||||
|
if (e.key === 'Escape') close();
|
||||||
|
else if (e.key === 'Enter' && e.target === nameInput) save();
|
||||||
|
}
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
|
||||||
|
overlay.querySelectorAll('[data-org-close]').forEach(b => b.addEventListener('click', close));
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
errorEl.style.display = 'none';
|
||||||
|
const name = nameInput.value.trim();
|
||||||
|
if (!name) { showError(t('admin.create_org.err_empty')); return; }
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = t('common.saving');
|
||||||
|
try {
|
||||||
|
const result = await api.adminCreateOrg(name);
|
||||||
|
if (typeof onSuccess === 'function') onSuccess(result);
|
||||||
|
window.location.reload();
|
||||||
|
} catch (err) {
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = t('admin.create_org.submit');
|
||||||
|
showError(err.message || t('admin.create_org.err_failed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function showError(msg) { errorEl.textContent = msg; errorEl.style.display = 'block'; }
|
||||||
|
|
||||||
|
saveBtn.addEventListener('click', save);
|
||||||
|
}
|
||||||
|
|
@ -25,6 +25,7 @@ export default {
|
||||||
'common.delete': 'Delete',
|
'common.delete': 'Delete',
|
||||||
'common.edit': 'Edit',
|
'common.edit': 'Edit',
|
||||||
'common.done': 'Done',
|
'common.done': 'Done',
|
||||||
|
'common.saving': 'Saving...',
|
||||||
'common.loading': 'Loading...',
|
'common.loading': 'Loading...',
|
||||||
'common.connected': 'Connected',
|
'common.connected': 'Connected',
|
||||||
'common.disconnected': 'Disconnected',
|
'common.disconnected': 'Disconnected',
|
||||||
|
|
@ -786,6 +787,15 @@ export default {
|
||||||
'admin.title': 'Platform Admin',
|
'admin.title': 'Platform Admin',
|
||||||
'admin.subtitle': 'Superadmin controls - only you can see this',
|
'admin.subtitle': 'Superadmin controls - only you can see this',
|
||||||
'admin.add_user': 'Add user',
|
'admin.add_user': 'Add user',
|
||||||
|
'admin.create_org.button': 'Create organization',
|
||||||
|
'admin.create_org.title': 'Create organization',
|
||||||
|
'admin.create_org.name': 'Organization name',
|
||||||
|
'admin.create_org.placeholder': 'e.g. Acme Corp',
|
||||||
|
'admin.create_org.hint': "Creates the organization and its first workspace, owned by you. Add the customer's users afterward with Add User.",
|
||||||
|
'admin.create_org.submit': 'Create',
|
||||||
|
'admin.create_org.success': 'Organization "{name}" created',
|
||||||
|
'admin.create_org.err_empty': 'Organization name cannot be empty',
|
||||||
|
'admin.create_org.err_failed': 'Could not create organization',
|
||||||
'admin.access_denied': 'Access Denied',
|
'admin.access_denied': 'Access Denied',
|
||||||
'admin.access_denied_desc': 'Platform admin access required.',
|
'admin.access_denied_desc': 'Platform admin access required.',
|
||||||
'admin.all_users': 'All Users',
|
'admin.all_users': 'All Users',
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { esc, isPlatformAdmin } from '../utils.js';
|
||||||
import { t } from '../i18n.js';
|
import { t } from '../i18n.js';
|
||||||
import { openAddUserModal } from '../components/workspace-members-add-user-modal.js';
|
import { openAddUserModal } from '../components/workspace-members-add-user-modal.js';
|
||||||
import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js';
|
import { openManageWorkspacesModal } from '../components/admin-user-workspaces-modal.js';
|
||||||
|
import { openCreateOrgModal } from '../components/admin-create-org-modal.js';
|
||||||
// Reuse the members view's server-error -> friendly-string mapper (handles the
|
// Reuse the members view's server-error -> friendly-string mapper (handles the
|
||||||
// 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a
|
// 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a
|
||||||
// second mapper.
|
// second mapper.
|
||||||
|
|
@ -58,8 +59,11 @@ export async function render(container) {
|
||||||
container.innerHTML = `
|
container.innerHTML = `
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div>
|
<div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div>
|
||||||
|
<div style="display:flex;gap:8px">
|
||||||
|
<button class="btn btn-secondary" id="adminCreateOrgBtn">${t('admin.create_org.button')}</button>
|
||||||
<button class="btn btn-primary" id="adminAddUserBtn">${t('admin.add_user')}</button>
|
<button class="btn btn-primary" id="adminAddUserBtn">${t('admin.add_user')}</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="settings-section">
|
<div class="settings-section">
|
||||||
<h3>${t('admin.all_users')}</h3>
|
<h3>${t('admin.all_users')}</h3>
|
||||||
|
|
@ -97,6 +101,15 @@ export async function render(container) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create Organization (#35): platform admin provisions a new customer org +
|
||||||
|
// its first workspace (owned by the admin). The modal reloads on success so
|
||||||
|
// the new org shows up in the switcher.
|
||||||
|
document.getElementById('adminCreateOrgBtn')?.addEventListener('click', () => {
|
||||||
|
openCreateOrgModal({
|
||||||
|
onSuccess: (result) => showToast(t('admin.create_org.success', { name: result.name }), 'success'),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
loadUsers();
|
loadUsers();
|
||||||
loadBranding();
|
loadBranding();
|
||||||
loadPlans();
|
loadPlans();
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,38 @@ router.post('/users', (req, res) => {
|
||||||
res.status(201).json({ ...created, workspace_id: ws.id, workspace_role: role });
|
res.status(201).json({ ...created, workspace_id: ws.id, workspace_role: role });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// POST /api/admin/orgs - create a new organization + its first ("Default")
|
||||||
|
// workspace (#35). Platform-admin only. The MSP use case: provision a customer
|
||||||
|
// org without the signup/auto-org path (AUTO_CREATE_ORG_ON_SIGNUP=false).
|
||||||
|
//
|
||||||
|
// organizations.owner_user_id is NOT NULL, so a brand-new org can't be ownerless.
|
||||||
|
// We make the creating platform admin the owner + workspace_admin (mirrors the
|
||||||
|
// signup org-bootstrap in routes/auth.js), which also surfaces the org in their
|
||||||
|
// switcher immediately. Customer users are then added via the Add User /
|
||||||
|
// manage-memberships flow.
|
||||||
|
router.post('/orgs', requirePlatformAdmin, (req, res) => {
|
||||||
|
const name = String(req.body?.name || '').trim();
|
||||||
|
if (!name) return res.status(400).json({ error: 'Organization name required' });
|
||||||
|
if (name.length > 120) return res.status(400).json({ error: 'Organization name must be 120 characters or fewer' });
|
||||||
|
|
||||||
|
const orgId = uuidv4();
|
||||||
|
const wsId = uuidv4();
|
||||||
|
const ownerId = req.user.id;
|
||||||
|
const txn = db.transaction(() => {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO organizations (id, name, owner_user_id, plan_id, subscription_status) VALUES (?, ?, ?, 'free', 'active')`
|
||||||
|
).run(orgId, name, ownerId);
|
||||||
|
db.prepare(`INSERT INTO organization_members (organization_id, user_id, role) VALUES (?, ?, 'org_owner')`).run(orgId, ownerId);
|
||||||
|
db.prepare(`INSERT INTO workspaces (id, organization_id, name, created_by) VALUES (?, ?, 'Default', ?)`).run(wsId, orgId, ownerId);
|
||||||
|
db.prepare(`INSERT INTO workspace_members (workspace_id, user_id, role) VALUES (?, ?, 'workspace_admin')`).run(wsId, ownerId);
|
||||||
|
});
|
||||||
|
txn();
|
||||||
|
|
||||||
|
req.workspaceId = wsId; // attribute the audit row to the new tenant
|
||||||
|
logActivity(req.user.id, 'admin_create_org', `org: ${name}`, null, getClientIp(req), wsId);
|
||||||
|
res.status(201).json({ id: orgId, name, owner_user_id: ownerId, workspace_id: wsId, workspace_name: 'Default' });
|
||||||
|
});
|
||||||
|
|
||||||
// PUT /api/admin/users/:id/workspace - move/assign a SINGLE-workspace user to a
|
// PUT /api/admin/users/:id/workspace - move/assign a SINGLE-workspace user to a
|
||||||
// different workspace (platform Users admin page). Platform-admin only: this is
|
// different workspace (platform Users admin page). Platform-admin only: this is
|
||||||
// a cross-org, platform-level action (requirePlatformAdmin excludes
|
// a cross-org, platform-level action (requirePlatformAdmin excludes
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,8 @@ db.exec(`
|
||||||
UNIQUE(workspace_id, user_id)
|
UNIQUE(workspace_id, user_id)
|
||||||
);
|
);
|
||||||
CREATE TABLE organizations (
|
CREATE TABLE organizations (
|
||||||
id TEXT PRIMARY KEY, name TEXT NOT NULL
|
id TEXT PRIMARY KEY, name TEXT NOT NULL,
|
||||||
|
owner_user_id TEXT, plan_id TEXT, subscription_status TEXT
|
||||||
);
|
);
|
||||||
CREATE TABLE activity_log (
|
CREATE TABLE activity_log (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
|
@ -346,3 +347,37 @@ test('membership mgmt: non-platform-admin denied (403)', async () => {
|
||||||
assert.equal((await ws('GET', 'u-mgmt', tokens.regular)).status, 403);
|
assert.equal((await ws('GET', 'u-mgmt', tokens.regular)).status, 403);
|
||||||
assert.equal((await ws('POST', 'u-mgmt', tokens.operator, { workspaceId: 'ws-a', role: 'workspace_viewer' })).status, 403);
|
assert.equal((await ws('POST', 'u-mgmt', tokens.operator, { workspaceId: 'ws-a', role: 'workspace_viewer' })).status, 403);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- #35: POST /api/admin/orgs (create org + first workspace, owned by admin) ---
|
||||||
|
test('platform_admin creates an org + Default workspace, owned by them (201)', async () => {
|
||||||
|
const res = await post('/api/admin/orgs', tokens.admin, { name: 'Bold Media Group' });
|
||||||
|
assert.equal(res.status, 201);
|
||||||
|
const body = await res.json();
|
||||||
|
assert.equal(body.name, 'Bold Media Group');
|
||||||
|
assert.ok(body.id && body.workspace_id, 'returns org id + workspace id');
|
||||||
|
assert.equal(body.owner_user_id, 'u-admin');
|
||||||
|
|
||||||
|
const org = db.prepare('SELECT * FROM organizations WHERE id=?').get(body.id);
|
||||||
|
assert.equal(org.owner_user_id, 'u-admin');
|
||||||
|
const ws = db.prepare('SELECT * FROM workspaces WHERE id=?').get(body.workspace_id);
|
||||||
|
assert.equal(ws.organization_id, body.id);
|
||||||
|
assert.equal(ws.name, 'Default');
|
||||||
|
assert.equal(db.prepare("SELECT role FROM organization_members WHERE organization_id=? AND user_id='u-admin'").get(body.id).role, 'org_owner');
|
||||||
|
assert.equal(db.prepare("SELECT role FROM workspace_members WHERE workspace_id=? AND user_id='u-admin'").get(body.workspace_id).role, 'workspace_admin');
|
||||||
|
// audited
|
||||||
|
assert.ok(db.prepare("SELECT 1 FROM activity_log WHERE action='admin_create_org'").get(), 'org creation audited');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create org: empty name is rejected (400), nothing created', async () => {
|
||||||
|
const before = db.prepare('SELECT COUNT(*) c FROM organizations').get().c;
|
||||||
|
const res = await post('/api/admin/orgs', tokens.admin, { name: ' ' });
|
||||||
|
assert.equal(res.status, 400);
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) c FROM organizations').get().c, before);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('create org: non-admin and operator denied (403)', async () => {
|
||||||
|
const before = db.prepare('SELECT COUNT(*) c FROM organizations').get().c;
|
||||||
|
assert.equal((await post('/api/admin/orgs', tokens.regular, { name: 'X' })).status, 403);
|
||||||
|
assert.equal((await post('/api/admin/orgs', tokens.operator, { name: 'Y' })).status, 403);
|
||||||
|
assert.equal(db.prepare('SELECT COUNT(*) c FROM organizations').get().c, before, 'no org created by denied callers');
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue