Merge pull request #49 from screentinker/feat/admin-create-org

feat(admin): Create Organization for platform admins (#35)
This commit is contained in:
screentinker 2026-06-09 09:10:20 -05:00 committed by GitHub
commit 36d1578794
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 165 additions and 2 deletions

View file

@ -177,6 +177,7 @@ export const api = {
// Admin-provisioned user creation (#10). data: { email, name, password,
// workspaceId, role, mustChangePassword }
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).
adminGetBranding: () => request('/admin/branding'),

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

View file

@ -25,6 +25,7 @@ export default {
'common.delete': 'Delete',
'common.edit': 'Edit',
'common.done': 'Done',
'common.saving': 'Saving...',
'common.loading': 'Loading...',
'common.connected': 'Connected',
'common.disconnected': 'Disconnected',
@ -786,6 +787,15 @@ export default {
'admin.title': 'Platform Admin',
'admin.subtitle': 'Superadmin controls - only you can see this',
'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_desc': 'Platform admin access required.',
'admin.all_users': 'All Users',

View file

@ -4,6 +4,7 @@ import { esc, isPlatformAdmin } from '../utils.js';
import { t } from '../i18n.js';
import { openAddUserModal } from '../components/workspace-members-add-user-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
// 409 duplicate-email / weak-password / invalid-email cases) so we don't fork a
// second mapper.
@ -58,7 +59,10 @@ export async function render(container) {
container.innerHTML = `
<div class="page-header">
<div><h1>${t('admin.title')}</h1><div class="subtitle">${t('admin.subtitle')}</div></div>
<button class="btn btn-primary" id="adminAddUserBtn">${t('admin.add_user')}</button>
<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>
</div>
</div>
<div class="settings-section">
@ -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();
loadBranding();
loadPlans();

View file

@ -101,6 +101,38 @@ router.post('/users', (req, res) => {
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
// different workspace (platform Users admin page). Platform-admin only: this is
// a cross-org, platform-level action (requirePlatformAdmin excludes

View file

@ -71,7 +71,8 @@ db.exec(`
UNIQUE(workspace_id, user_id)
);
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 (
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('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');
});