diff --git a/frontend/js/api.js b/frontend/js/api.js
index 8e2a9b4..04d9ed0 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -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'),
diff --git a/frontend/js/components/admin-create-org-modal.js b/frontend/js/components/admin-create-org-modal.js
new file mode 100644
index 0000000..65a2467
--- /dev/null
+++ b/frontend/js/components/admin-create-org-modal.js
@@ -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 = `
+
+ `;
+ 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);
+}
diff --git a/frontend/js/i18n/en.js b/frontend/js/i18n/en.js
index a643255..0597981 100644
--- a/frontend/js/i18n/en.js
+++ b/frontend/js/i18n/en.js
@@ -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',
diff --git a/frontend/js/views/admin.js b/frontend/js/views/admin.js
index 2fe0153..cc183a3 100644
--- a/frontend/js/views/admin.js
+++ b/frontend/js/views/admin.js
@@ -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 = `
@@ -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();
diff --git a/server/routes/admin.js b/server/routes/admin.js
index 2ed955e..c2325e1 100644
--- a/server/routes/admin.js
+++ b/server/routes/admin.js
@@ -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
diff --git a/server/test/admin-users.test.js b/server/test/admin-users.test.js
index 0b62207..2ce1a83 100644
--- a/server/test/admin-users.test.js
+++ b/server/test/admin-users.test.js
@@ -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');
+});