diff --git a/frontend/js/api.js b/frontend/js/api.js
index 04d9ed0..f5c38a1 100644
--- a/frontend/js/api.js
+++ b/frontend/js/api.js
@@ -178,6 +178,9 @@ export const api = {
// 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 }) }),
+ adminListOrgs: () => request('/admin/orgs'),
+ adminDeleteOrg: (id) => request(`/admin/orgs/${id}`, { method: 'DELETE' }),
+ adminDeleteWorkspace: (id) => request(`/admin/workspaces/${id}`, { method: 'DELETE' }),
// Instance-level default branding (#15, platform admin).
adminGetBranding: () => request('/admin/branding'),
diff --git a/frontend/js/components/type-to-confirm-modal.js b/frontend/js/components/type-to-confirm-modal.js
new file mode 100644
index 0000000..ef8da1f
--- /dev/null
+++ b/frontend/js/components/type-to-confirm-modal.js
@@ -0,0 +1,75 @@
+import { t } from '../i18n.js';
+
+// Reusable destructive-confirmation modal (#36). The primary (danger) button stays
+// disabled until the user types `expected` exactly — guards irreversible deletes
+// (delete org / workspace). opts:
+// title, body (HTML allowed - caller escapes), expected (string to type),
+// confirmLabel, onConfirm: async () => any (throw to show an inline error)
+export function openTypeToConfirmModal(opts = {}) {
+ const { title, body = '', expected, confirmLabel, onConfirm } = opts;
+ const overlay = document.createElement('div');
+ overlay.className = 'modal-overlay';
+ overlay.innerHTML = `
+
${t('admin.branding.title')}
${t('admin.branding.desc')}
@@ -111,12 +118,83 @@ export async function render(container) {
});
loadUsers();
+ loadOrgs();
loadBranding();
loadPlans();
loadSystem();
}
+// #36: list organizations with owner + resource counts; platform admin can
+// cascade-delete an org or an individual workspace (type-the-name confirm).
+async function loadOrgs() {
+ const el = document.getElementById('orgsTable');
+ if (!el) return;
+ let orgs;
+ try {
+ orgs = await api.adminListOrgs();
+ } catch (err) {
+ el.innerHTML = `
${esc(err.message || 'Failed to load organizations')}
`;
+ return;
+ }
+ if (!orgs.length) {
+ el.innerHTML = `
${t('admin.orgs.empty')}
`;
+ return;
+ }
+ el.innerHTML = orgs.map(o => {
+ const wsRows = (o.workspaces || []).map(w => `
+
+
${esc(w.name)}
+ · ${w.device_count} ${t('admin.orgs.devices')} · ${w.member_count} ${t('admin.orgs.members')}
+
+
+
`).join('');
+ return `
+
+
+
+
${esc(o.name)}
+
+ ${t('admin.orgs.owner')}: ${esc(o.owner_email || '—')} ·
+ ${o.workspace_count} ${t('admin.orgs.workspaces')} · ${o.device_count} ${t('admin.orgs.devices')} · ${o.member_count} ${t('admin.orgs.members')}
+
+
+
+
+ ${wsRows}
+
`;
+ }).join('');
+
+ el.querySelectorAll('[data-del-org]').forEach(btn => btn.addEventListener('click', () => {
+ const id = btn.dataset.delOrg, name = btn.dataset.orgName;
+ openTypeToConfirmModal({
+ title: t('admin.orgs.delete_org_title'),
+ body: t('admin.orgs.delete_org_body', { name: esc(name) }),
+ expected: name,
+ confirmLabel: t('admin.orgs.delete_org'),
+ onConfirm: async () => {
+ await api.adminDeleteOrg(id);
+ showToast(t('admin.orgs.org_deleted', { name }), 'success');
+ loadOrgs(); loadUsers();
+ },
+ });
+ }));
+ el.querySelectorAll('[data-del-ws]').forEach(btn => btn.addEventListener('click', () => {
+ const id = btn.dataset.delWs, name = btn.dataset.wsName;
+ openTypeToConfirmModal({
+ title: t('admin.orgs.delete_ws_title'),
+ body: t('admin.orgs.delete_ws_body', { name: esc(name) }),
+ expected: name,
+ confirmLabel: t('admin.orgs.delete_ws'),
+ onConfirm: async () => {
+ await api.adminDeleteWorkspace(id);
+ showToast(t('admin.orgs.ws_deleted', { name }), 'success');
+ loadOrgs();
+ },
+ });
+ }));
+}
+
// #15: instance-level default branding form (platform default; every workspace
// without its own white-label inherits this, as does the login page).
async function loadBranding() {
diff --git a/server/lib/user-deletion.js b/server/lib/user-deletion.js
index 2bedc6a..83c82bb 100644
--- a/server/lib/user-deletion.js
+++ b/server/lib/user-deletion.js
@@ -54,6 +54,55 @@ const REASSIGN_USER_TABLES = [
'playlists', 'schedules', 'video_walls', 'device_groups', 'kiosk_pages', 'white_labels', 'alert_configs',
];
+const inClause = n => Array.from({ length: n }, () => '?').join(',');
+function tablesPresent(db) {
+ return new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name));
+}
+
+// Delete the given workspaces and every tenant resource inside them. The
+// workspace-scoped tables are NO ACTION (won't cascade from the workspace), so
+// we delete them explicitly first; their CASCADE children (playlist_items,
+// telemetry, assignments, layout_zones, *_devices) and workspace_members/invites
+// clean themselves up. MUST run inside a transaction with defer_foreign_keys=ON.
+// `have` is the set of existing table names (tablesPresent()).
+function purgeWorkspaces(db, wsIds, have) {
+ if (!wsIds.length) return;
+ const wph = inClause(wsIds.length);
+ if (have.has('devices')) {
+ const devIds = db.prepare(`SELECT id FROM devices WHERE workspace_id IN (${wph})`).all(...wsIds).map(r => r.id);
+ if (devIds.length) {
+ const dph = inClause(devIds.length);
+ for (const lt of DEVICE_LOG_TABLES) if (have.has(lt)) db.prepare(`DELETE FROM ${lt} WHERE device_id IN (${dph})`).run(...devIds);
+ }
+ }
+ for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds);
+ if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds);
+ db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites
+}
+
+// #36: cascade-delete a single workspace (and all its tenant resources). The
+// parent org is left intact. Platform-admin action; callers gate authorization.
+function deleteWorkspaceCascade(db, { workspaceId }) {
+ db.transaction(() => {
+ db.pragma('defer_foreign_keys = ON');
+ purgeWorkspaces(db, [workspaceId], tablesPresent(db));
+ })();
+}
+
+// #36: cascade-delete an organization - all its workspaces + tenant resources,
+// then the org itself (cascades organization_members). Member USERS are NOT
+// deleted (they may belong to other orgs); they simply lose this membership.
+function deleteOrgCascade(db, { orgId }) {
+ db.transaction(() => {
+ db.pragma('defer_foreign_keys = ON');
+ const have = tablesPresent(db);
+ const wsIds = db.prepare('SELECT id FROM workspaces WHERE organization_id = ?').all(orgId).map(r => r.id);
+ purgeWorkspaces(db, wsIds, have);
+ if (have.has('activity_log')) db.prepare('UPDATE activity_log SET organization_id = NULL WHERE organization_id = ?').run(orgId);
+ db.prepare('DELETE FROM organizations WHERE id = ?').run(orgId); // cascades organization_members
+ })();
+}
+
function listOwnedOrgsWithSharing(db, userId) {
let orgs = [];
try { orgs = db.prepare('SELECT id FROM organizations WHERE owner_user_id = ?').all(userId); }
@@ -84,8 +133,7 @@ function deleteUserCascade(db, { targetId, actingAdminId }) {
}
const soloOrgIds = owned.map(o => o.id);
- const have = new Set(db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all().map(r => r.name));
- const inClause = n => Array.from({ length: n }, () => '?').join(',');
+ const have = tablesPresent(db);
const run = db.transaction(() => {
// FK checks deferred to COMMIT: order of our deletes no longer matters, only
@@ -97,20 +145,7 @@ function deleteUserCascade(db, { targetId, actingAdminId }) {
const wsIds = db.prepare(
`SELECT id FROM workspaces WHERE organization_id IN (${inClause(soloOrgIds.length)})`
).all(...soloOrgIds).map(r => r.id);
-
- if (wsIds.length) {
- const wph = inClause(wsIds.length);
- if (have.has('devices')) {
- const devIds = db.prepare(`SELECT id FROM devices WHERE workspace_id IN (${wph})`).all(...wsIds).map(r => r.id);
- if (devIds.length) {
- const dph = inClause(devIds.length);
- for (const lt of DEVICE_LOG_TABLES) if (have.has(lt)) db.prepare(`DELETE FROM ${lt} WHERE device_id IN (${dph})`).run(...devIds);
- }
- }
- for (const t of WORKSPACE_SCOPED) if (have.has(t)) db.prepare(`DELETE FROM ${t} WHERE workspace_id IN (${wph})`).run(...wsIds);
- if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET workspace_id = NULL WHERE workspace_id IN (${wph})`).run(...wsIds);
- db.prepare(`DELETE FROM workspaces WHERE id IN (${wph})`).run(...wsIds); // cascades workspace_members/invites
- }
+ purgeWorkspaces(db, wsIds, have);
const oph = inClause(soloOrgIds.length);
if (have.has('activity_log')) db.prepare(`UPDATE activity_log SET organization_id = NULL WHERE organization_id IN (${oph})`).run(...soloOrgIds);
@@ -146,4 +181,7 @@ function deleteUserCascade(db, { targetId, actingAdminId }) {
run();
}
-module.exports = { deleteUserCascade, OrgHasOtherMembersError, listOwnedOrgsWithSharing };
+module.exports = {
+ deleteUserCascade, OrgHasOtherMembersError, listOwnedOrgsWithSharing,
+ deleteWorkspaceCascade, deleteOrgCascade,
+};
diff --git a/server/routes/admin.js b/server/routes/admin.js
index c2325e1..f33873b 100644
--- a/server/routes/admin.js
+++ b/server/routes/admin.js
@@ -6,6 +6,7 @@ const { db } = require('../db/database');
const { canAdminWorkspace } = require('../lib/permissions');
const { requirePlatformAdmin } = require('../middleware/auth');
const { logActivity, getClientIp } = require('../services/activity');
+const { deleteWorkspaceCascade, deleteOrgCascade } = require('../lib/user-deletion');
const { platformDefaultRow, HARDCODED_BRANDING, PLATFORM_DEFAULT_ID } = require('../lib/branding');
// Admin-provisioned user creation (#10). Operates on a target workspace
@@ -133,6 +134,59 @@ router.post('/orgs', requirePlatformAdmin, (req, res) => {
res.status(201).json({ id: orgId, name, owner_user_id: ownerId, workspace_id: wsId, workspace_name: 'Default' });
});
+// GET /api/admin/orgs - list every organization with owner + resource counts and
+// its workspaces (#36, drives the Organizations admin section). Platform-admin only.
+router.get('/orgs', requirePlatformAdmin, (req, res) => {
+ const orgs = db.prepare(`
+ SELECT o.id, o.name, o.created_at, u.email AS owner_email, u.name AS owner_name,
+ (SELECT COUNT(*) FROM organization_members m WHERE m.organization_id = o.id) AS member_count,
+ (SELECT COUNT(*) FROM workspaces w WHERE w.organization_id = o.id) AS workspace_count,
+ (SELECT COUNT(*) FROM devices d JOIN workspaces w ON w.id = d.workspace_id WHERE w.organization_id = o.id) AS device_count
+ FROM organizations o
+ LEFT JOIN users u ON u.id = o.owner_user_id
+ ORDER BY o.created_at DESC
+ `).all();
+ const wsByOrg = {};
+ for (const w of db.prepare(`
+ SELECT w.id, w.name, w.organization_id,
+ (SELECT COUNT(*) FROM devices d WHERE d.workspace_id = w.id) AS device_count,
+ (SELECT COUNT(*) FROM workspace_members m WHERE m.workspace_id = w.id) AS member_count
+ FROM workspaces w ORDER BY w.created_at
+ `).all()) {
+ (wsByOrg[w.organization_id] = wsByOrg[w.organization_id] || []).push(w);
+ }
+ res.json(orgs.map(o => ({ ...o, workspaces: wsByOrg[o.id] || [] })));
+});
+
+// DELETE /api/admin/orgs/:id - cascade-delete an org and everything in it (#36).
+// Platform-admin only. The frontend requires a type-the-name confirmation; this
+// is irreversible. Uses the shared cascade helper so no tenant resource is orphaned.
+router.delete('/orgs/:id', requirePlatformAdmin, (req, res) => {
+ const org = db.prepare('SELECT id, name FROM organizations WHERE id = ?').get(req.params.id);
+ if (!org) return res.status(404).json({ error: 'Organization not found' });
+ try {
+ deleteOrgCascade(db, { orgId: org.id });
+ } catch (e) {
+ return res.status(500).json({ error: 'Failed to delete organization' });
+ }
+ logActivity(req.user.id, 'admin_delete_org', `org: ${org.name} (${org.id})`, null, getClientIp(req), null);
+ res.json({ deleted: true, id: org.id });
+});
+
+// DELETE /api/admin/workspaces/:id - cascade-delete a single workspace + its
+// tenant resources (#36); the parent org is left intact. Platform-admin only.
+router.delete('/workspaces/:id', requirePlatformAdmin, (req, res) => {
+ const ws = db.prepare('SELECT id, name, organization_id FROM workspaces WHERE id = ?').get(req.params.id);
+ if (!ws) return res.status(404).json({ error: 'Workspace not found' });
+ try {
+ deleteWorkspaceCascade(db, { workspaceId: ws.id });
+ } catch (e) {
+ return res.status(500).json({ error: 'Failed to delete workspace' });
+ }
+ logActivity(req.user.id, 'admin_delete_workspace', `workspace: ${ws.name} (${ws.id})`, null, getClientIp(req), null);
+ res.json({ deleted: true, id: ws.id });
+});
+
// 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 2ce1a83..4a690bf 100644
--- a/server/test/admin-users.test.js
+++ b/server/test/admin-users.test.js
@@ -82,6 +82,7 @@ db.exec(`
details TEXT,
ip_address TEXT,
workspace_id TEXT,
+ organization_id TEXT,
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
);
`);
@@ -381,3 +382,40 @@ test('create org: non-admin and operator denied (403)', async () => {
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');
});
+
+// --- #36: DELETE /api/admin/orgs/:id and /workspaces/:id ---
+const delReq = (pathname, token) => fetch(base + pathname, {
+ method: 'DELETE', headers: token ? { Authorization: `Bearer ${token}` } : {},
+});
+
+test('platform_admin deletes an org (200): org + workspace + members removed', async () => {
+ db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-del','Del Org','u-admin')").run();
+ db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES ('org-del','u-admin','org_owner')").run();
+ db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-del','org-del','Del WS')").run();
+ db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('ws-del','u-admin','workspace_admin')").run();
+
+ const res = await delReq('/api/admin/orgs/org-del', tokens.admin);
+ assert.equal(res.status, 200);
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM organizations WHERE id='org-del'").get().c, 0);
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM workspaces WHERE id='ws-del'").get().c, 0);
+ // member-table FK cascade is verified against the real cascaded FKs in
+ // user-deletion.test.js (this minimal harness models no FKs).
+ assert.ok(db.prepare("SELECT 1 FROM activity_log WHERE action='admin_delete_org'").get(), 'audited');
+});
+
+test('delete org: 404 unknown; 403 for non-admin + operator (no delete)', async () => {
+ assert.equal((await delReq('/api/admin/orgs/nope', tokens.admin)).status, 404);
+ db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-keep','Keep','u-admin')").run();
+ assert.equal((await delReq('/api/admin/orgs/org-keep', tokens.regular)).status, 403);
+ assert.equal((await delReq('/api/admin/orgs/org-keep', tokens.operator)).status, 403);
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM organizations WHERE id='org-keep'").get().c, 1, 'denied callers did not delete');
+});
+
+test('platform_admin deletes a workspace (200): ws gone, parent org intact', async () => {
+ db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('org-wd','WD','u-admin')").run();
+ db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('ws-wd','org-wd','WD WS')").run();
+ const res = await delReq('/api/admin/workspaces/ws-wd', tokens.admin);
+ assert.equal(res.status, 200);
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM workspaces WHERE id='ws-wd'").get().c, 0);
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM organizations WHERE id='org-wd'").get().c, 1, 'org intact');
+});
diff --git a/server/test/user-deletion.test.js b/server/test/user-deletion.test.js
index fba6fe8..1a05b08 100644
--- a/server/test/user-deletion.test.js
+++ b/server/test/user-deletion.test.js
@@ -192,3 +192,44 @@ test('missing user -> 404', async () => {
const res = await del('does-not-exist', tokens.admin);
assert.equal(res.status, 404);
});
+
+// --- #36: deleteOrgCascade / deleteWorkspaceCascade (shared cascade helpers) ---
+const { deleteOrgCascade, deleteWorkspaceCascade } = require('../lib/user-deletion');
+
+test('deleteOrgCascade: org + all workspaces/resources/members gone; member users kept', () => {
+ user('u-cust');
+ db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('orgD','Cust','u-cust')").run();
+ db.prepare("INSERT INTO organization_members (organization_id, user_id, role) VALUES ('orgD','u-cust','org_owner')").run();
+ db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('wsD1','orgD','D1'),('wsD2','orgD','D2')").run();
+ db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('wsD1','u-cust','workspace_admin')").run();
+ db.prepare("INSERT INTO devices (id, workspace_id) VALUES ('dvD','wsD1')").run();
+ db.prepare("INSERT INTO content (id, workspace_id) VALUES ('ctD','wsD2')").run();
+ db.prepare("INSERT INTO playlists (id, user_id, workspace_id) VALUES ('plD','u-cust','wsD1')").run();
+
+ deleteOrgCascade(db, { orgId: 'orgD' });
+
+ assert.equal(exists('organizations','orgD'), false, 'org gone');
+ assert.equal(exists('workspaces','wsD1') || exists('workspaces','wsD2'), false, 'workspaces gone');
+ assert.equal(exists('devices','dvD'), false, 'device gone');
+ assert.equal(exists('content','ctD'), false, 'content gone');
+ assert.equal(exists('playlists','plD'), false, 'playlist gone');
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM organization_members WHERE organization_id='orgD'").get().c, 0, 'org members cascaded');
+ assert.equal(exists('users','u-cust'), true, 'member user is NOT deleted');
+});
+
+test('deleteWorkspaceCascade: one workspace + resources gone; org + sibling intact', () => {
+ user('u-cust2');
+ db.prepare("INSERT INTO organizations (id, name, owner_user_id) VALUES ('orgW','W','u-cust2')").run();
+ db.prepare("INSERT INTO workspaces (id, organization_id, name) VALUES ('wsW1','orgW','W1'),('wsW2','orgW','W2')").run();
+ db.prepare("INSERT INTO workspace_members (workspace_id, user_id, role) VALUES ('wsW1','u-cust2','workspace_admin')").run();
+ db.prepare("INSERT INTO devices (id, workspace_id) VALUES ('dvW','wsW1'),('dvW2','wsW2')").run();
+
+ deleteWorkspaceCascade(db, { workspaceId: 'wsW1' });
+
+ assert.equal(exists('workspaces','wsW1'), false, 'target ws gone');
+ assert.equal(exists('devices','dvW'), false, 'target ws device gone');
+ assert.equal(db.prepare("SELECT COUNT(*) c FROM workspace_members WHERE workspace_id='wsW1'").get().c, 0, 'members cascaded');
+ assert.equal(exists('organizations','orgW'), true, 'org intact');
+ assert.equal(exists('workspaces','wsW2'), true, 'sibling ws intact');
+ assert.equal(exists('devices','dvW2'), true, 'sibling device intact');
+});