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