mirror of
https://github.com/screentinker/screentinker.git
synced 2026-06-15 02:33:15 -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,
|
||||
// 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'),
|
||||
|
|
|
|||
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.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',
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue