diff --git a/docs/operating.md b/docs/operating.md
index 7ef184e..be712a6 100644
--- a/docs/operating.md
+++ b/docs/operating.md
@@ -181,10 +181,14 @@ run somewhat more. Nothing here prices anything or writes to hotdata.
## Platform operators (system admins)
Creating or deleting an organization provisions or strands a hotdata database,
-so it is a platform power, distinct from org admin. System admins get an **All
-organizations** card on `/admin`: every org with member counts, a create form
+so it is a platform power, distinct from org admin. The system admin gets a
+**System** item in the top nav (drawn for nobody else; `/api/orgs` says who)
+leading to `/system`, the home for platform-level views: totals across the
+server and **All organizations** -- every org with member counts, a create form
(optionally with an owner email, which mints an invite whose acceptor becomes
-that org's admin), and delete for empty orgs.
+that org's admin), and delete for empty orgs. The page and its read,
+`/api/system/state`, refuse everyone but a system admin (redirect and 403
+respectively); the Organization page is about one team only.
```bash
.venv/bin/python server/server.py makesysadmin eddie@hotdata.dev
diff --git a/server/server.py b/server/server.py
index 944eb68..4e3fccc 100644
--- a/server/server.py
+++ b/server/server.py
@@ -2951,6 +2951,21 @@ def do_GET(self):
return
self._static("admin.html")
return
+ if path == "/system":
+ # platform views: one account on the server has them. Anyone
+ # else is sent to their dashboard rather than shown a 403 page
+ # -- there is nothing for them to do here.
+ viewer = self._viewer()
+ if not viewer:
+ self._redirect("/login?next=" + quote(self.path))
+ return
+ if self._gated_on_verify(viewer, path):
+ return
+ if not self.auth.is_system_admin(viewer["email"]):
+ self._redirect("/")
+ return
+ self._static("system.html")
+ return
if path == "/device":
q = parse_qs(parsed.query)
viewer = self._viewer()
@@ -3086,6 +3101,22 @@ def do_GET(self):
self._send(200, body, "application/json", gzipped=True)
else:
self._json(self._org_payload(viewer, fresh=fresh, days=days))
+ elif path == "/api/system/state":
+ # the platform view: every organization with its member count.
+ # Gated on system admin, not org admin -- an org admin runs one
+ # team and has no business seeing the others' names and sizes.
+ if not self.auth.is_system_admin(viewer["email"]):
+ self._json({"error": "only a system admin can see this"}, 403)
+ return
+ orgs = self.auth.all_orgs()
+ self._json({
+ "viewer": {"email": viewer["email"], "org": viewer.get("org_name"),
+ "orgSlug": viewer["org_slug"]},
+ "orgs": orgs,
+ "totals": {"organizations": len(orgs),
+ "members": sum(o["members"] for o in orgs),
+ "empty": sum(1 for o in orgs if not o["members"])},
+ })
elif path == "/api/admin/state":
org, email = viewer["org_slug"], viewer["email"]
# the privileged reads stay behind the role checks, so this is
@@ -3093,9 +3124,9 @@ def do_GET(self):
# then the reads that answer depends on it. Wave one is a
# roster read, which already carries the admin set and the
# active emails that wave two would otherwise re-query.
- who = gather(is_sysadmin=lambda: self.auth.is_system_admin(email),
- roster=lambda: self.auth.roster(org))
- roster = who["roster"]
+ # (the platform-wide list used to ride along here for system
+ # admins; it has its own page and read now, /system)
+ roster = self.auth.roster(org)
is_admin = email in roster["admins"]
payload = {
"viewer": {"email": email, "isAdmin": is_admin},
@@ -3107,9 +3138,6 @@ def do_GET(self):
extra["invites"] = lambda: self.auth.org_invites(org)
extra["collectors"] = lambda: self.auth.org_collectors(
org, active=roster["active"])
- if who["is_sysadmin"]:
- extra["allOrgs"] = lambda: self.auth.all_orgs()
- payload["viewer"]["isSystemAdmin"] = True
payload.update(gather(extra))
self._json(payload)
elif path == "/api/orgs":
@@ -3117,7 +3145,10 @@ def do_GET(self):
found = gather({s: (lambda s=s: self.auth.get_org(s)) for s in slugs})
orgs = [{"slug": s, "name": (found[s] or {}).get("name") or s}
for s in slugs]
- self._json({"active": viewer["org_slug"], "orgs": orgs})
+ # the nav draws the System link from this, so the one answer
+ # it already fetches decides it; a bare yes/no is all it needs
+ self._json({"active": viewer["org_slug"], "orgs": orgs,
+ "isSystemAdmin": self.auth.is_system_admin(viewer["email"])})
elif path == "/api/status":
self._json({"ok": True, "org": viewer["org_slug"]})
elif path.startswith("/api/session/"):
diff --git a/server/static/admin.css b/server/static/admin.css
index 861275b..ffb36cb 100644
--- a/server/static/admin.css
+++ b/server/static/admin.css
@@ -84,3 +84,9 @@
.cardfoot input { flex: 1 1 100%; min-width: 0; }
.linkout input { min-width: 0; }
}
+
+/* System page: the platform totals row. Three figures, not a table. */
+.totals { display: flex; gap: 28px; flex-wrap: wrap; padding: 4px 0 2px; }
+.total { display: flex; flex-direction: column; gap: 2px; }
+.total .num { font-size: 26px; font-weight: 600; color: var(--ink); font-variant-numeric: tabular-nums; }
+.total .lbl { font-size: 12px; color: var(--ink-2); }
diff --git a/server/static/admin.html b/server/static/admin.html
index aea0e22..6189ff2 100644
--- a/server/static/admin.html
+++ b/server/static/admin.html
@@ -53,39 +53,6 @@
Members
-
-
-
All organizations
-
- System admin
-
-
-
-
-
Organization
Slug
Members
Created
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
Pending invites
diff --git a/server/static/admin.js b/server/static/admin.js
index f532669..e564a04 100644
--- a/server/static/admin.js
+++ b/server/static/admin.js
@@ -1,7 +1,8 @@
'use strict';
// ---------------------------------------------------------------------------
-// Organization admin: members, invites, signed-in collectors.
+// Organization admin: members, invites, signed-in collectors. Platform-wide
+// views (every organization) live on /system, for the system admin only.
// Everything here is org-scoped server-side; this page only draws what
// /api/admin/state returns for the logged-in viewer.
// ---------------------------------------------------------------------------
@@ -35,7 +36,7 @@ function clearPlaceholders() {
// An error belongs beside the control that raised it: the banner at the top of
// the page is off-screen by the time someone is acting on the third card.
// '#err' remains the fallback, and is where a failed page load reports.
-const ERROR_BOXES = ['#err', '#inviteErr', '#orgErr'];
+const ERROR_BOXES = ['#err', '#inviteErr'];
function clearErrors() {
for (const sel of ERROR_BOXES) {
@@ -225,24 +226,6 @@ function renderCollectors() {
}
}
-function renderOrgs() {
- const t = tbody('#orgs');
- t.replaceChildren();
- const orgs = state.allOrgs || [];
- for (const o of orgs) {
- const controls = el('div', { class: 'rowactions' });
- if (!o.members && o.slug !== state.org.slug) {
- controls.append(action('Delete', true, () => {
- if (!confirm(`Delete '${o.slug}'? Its database is kept.`)) return;
- act(() => api('/api/admin/delete-org', { slug: o.slug }), '#orgErr');
- }));
- }
- t.append(row(
- o.name + (o.slug === state.org.slug ? ' (yours)' : ''),
- o.slug, String(o.members), day(o.created_at), controls));
- }
-}
-
// ---------------------------------------------------------------------------
// Invite dialog: both link kinds behind one action.
// ---------------------------------------------------------------------------
@@ -300,11 +283,8 @@ async function load() {
$('#orgName').disabled = !admin;
$('#orgSave').disabled = !admin;
for (const n of document.querySelectorAll('.adminonly')) n.hidden = !admin;
- const sys = !!state.viewer.isSystemAdmin;
- for (const n of document.querySelectorAll('.sysonly')) n.hidden = !sys;
renderMembers();
if (admin) { renderInvites(); renderCollectors(); }
- if (sys) renderOrgs();
}
// ---------------------------------------------------------------------------
@@ -347,28 +327,12 @@ $('#inviteCreate').addEventListener('click', async () => {
}
});
-$('#orgCreate').addEventListener('click', () => act(async () => {
- const r = await api('/api/admin/create-org', {
- name: $('#newOrgName').value.trim(),
- owner_email: $('#newOrgOwner').value.trim(),
- });
- $('#newOrgName').value = ''; $('#newOrgOwner').value = '';
- $('#orgLinkOut').hidden = true; // never show a previous org's link
- if (r.invite_link) {
- $('#orgLinkValue').value = r.invite_link;
- $('#orgLinkValue').title = 'send this to the owner; they join as the org admin';
- $('#orgLinkOut').hidden = false;
- $('#orgLinkValue').select();
- }
-}, '#orgErr'));
-
function copyField(sel) {
const f = $(sel);
f.select();
if (navigator.clipboard) navigator.clipboard.writeText(f.value);
else document.execCommand('copy');
}
-$('#orgLinkCopy').addEventListener('click', () => copyField('#orgLinkValue'));
$('#linkCopy').addEventListener('click', () => copyField('#linkValue'));
load().catch((e) => showError(e.message));
diff --git a/server/static/nav.js b/server/static/nav.js
index 6baf7bd..fdd544d 100644
--- a/server/static/nav.js
+++ b/server/static/nav.js
@@ -69,7 +69,19 @@
// Switching changes the ACTIVE org server-side (dashboard + ingest routing),
// then reloads so every view re-reads it.
fetch('/api/orgs').then((r) => (r.ok ? r.json() : null)).then((d) => {
- if (!d || !d.orgs || d.orgs.length < 2) return;
+ if (!d) return;
+ // System: the platform views, drawn only for the account that has them.
+ // The server refuses everyone else regardless; this just keeps a link
+ // that would bounce out of everyone else's header.
+ if (d.isSystemAdmin) {
+ links.append(node('a', {
+ class: 'navlink navsys' + (current === 'system' ? ' current' : ''),
+ href: '/system',
+ text: 'System',
+ ...(current === 'system' ? { 'aria-current': 'page' } : {}),
+ }));
+ }
+ if (!d.orgs || d.orgs.length < 2) return;
orgList.append(node('div', { class: 'menu-email', text: 'Switch organization' }));
for (const o of d.orgs) {
const isActive = o.slug === d.active;
diff --git a/server/static/system.html b/server/static/system.html
new file mode 100644
index 0000000..ad55737
--- /dev/null
+++ b/server/static/system.html
@@ -0,0 +1,73 @@
+
+
+
+
+
+ hototel - system
+
+
+
+
+
+
+
+
+
+
+
+
+
Platform
+
+ System admin
+
+
+
organizations
+
members
+
empty organizations
+
+
+
+
+
+
All organizations
+
+
+
+
+
+
+
Organization
Slug
Members
Created
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/static/system.js b/server/static/system.js
new file mode 100644
index 0000000..5792db5
--- /dev/null
+++ b/server/static/system.js
@@ -0,0 +1,132 @@
+'use strict';
+
+// ---------------------------------------------------------------------------
+// System: platform-wide views for the one account that is system admin.
+// The server decides who that is (/system and /api/system/state both refuse
+// everyone else); this page only draws the answer. Organization-level admin
+// stays on /admin.
+// ---------------------------------------------------------------------------
+const $ = (sel) => document.querySelector(sel);
+const tbody = (sel) => document.querySelector(sel + ' tbody');
+
+function el(tag, attrs, ...children) {
+ const n = document.createElement(tag);
+ for (const [k, v] of Object.entries(attrs || {})) {
+ if (k === 'class') n.className = v;
+ else if (k === 'text') n.textContent = v;
+ else if (k.startsWith('on')) n.addEventListener(k.slice(2), v);
+ else n.setAttribute(k, v);
+ }
+ for (const c of children) if (c != null) n.append(c);
+ return n;
+}
+
+let state = null;
+
+function clearPlaceholders() {
+ for (const r of document.querySelectorAll('.admintable tr.placeholder')) r.remove();
+}
+
+function clearErrors() {
+ for (const sel of ['#err', '#orgErr']) {
+ const box = $(sel);
+ box.textContent = ''; box.hidden = true;
+ }
+}
+
+function showError(msg, boxSel) {
+ const box = (boxSel && $(boxSel)) || $('#err');
+ box.textContent = msg;
+ box.hidden = !msg;
+ if (msg) clearPlaceholders();
+}
+
+async function api(path, body) {
+ const r = await fetch(path, {
+ method: body ? 'POST' : 'GET',
+ headers: body ? { 'Content-Type': 'application/json' } : {},
+ body: body ? JSON.stringify(body) : undefined,
+ });
+ if (r.status === 401) { location.href = '/login'; throw new Error('signed out'); }
+ // not the system admin: the page route would have bounced, but a session
+ // can lose the grant while the tab is open
+ if (r.status === 403) { location.href = '/'; throw new Error('not allowed'); }
+ const data = await r.json();
+ if (!r.ok) throw new Error(data.error || r.statusText);
+ return data;
+}
+
+const day = (v) => (v ? String(v).slice(0, 10) : '-');
+
+function row(...cells) {
+ return el('tr', {}, ...cells.map((c) =>
+ el('td', {}, typeof c === 'string' ? document.createTextNode(c) : c)));
+}
+
+function action(label, danger, onclick) {
+ return el('button',
+ { class: danger ? 'linkbtn danger' : 'linkbtn', type: 'button', text: label, onclick });
+}
+
+async function act(fn, boxSel) {
+ clearErrors();
+ try { await fn(); await load(); } catch (e) { showError(e.message, boxSel); }
+}
+
+function renderOrgs() {
+ const t = tbody('#orgs');
+ t.replaceChildren();
+ const mine = state.viewer.orgSlug;
+ for (const o of state.orgs) {
+ const controls = el('div', { class: 'rowactions' });
+ if (!o.members && o.slug !== mine) {
+ controls.append(action('Delete', true, () => {
+ if (!confirm(`Delete '${o.slug}'? Its database is kept.`)) return;
+ act(() => api('/api/admin/delete-org', { slug: o.slug }), '#orgErr');
+ }));
+ }
+ t.append(row(
+ o.name + (o.slug === mine ? ' (yours)' : ''),
+ o.slug, String(o.members), day(o.created_at), controls));
+ }
+ $('#orgCount').textContent = `${state.orgs.length} total`;
+}
+
+function renderTotals() {
+ const t = state.totals;
+ $('#totOrgs').textContent = String(t.organizations);
+ $('#totMembers').textContent = String(t.members);
+ $('#totEmpty').textContent = String(t.empty);
+}
+
+async function load() {
+ state = await api('/api/system/state');
+ clearPlaceholders();
+ window.setViewer(state.viewer.email, state.viewer.org);
+ renderTotals();
+ renderOrgs();
+}
+
+$('#orgCreate').addEventListener('click', () => act(async () => {
+ const r = await api('/api/admin/create-org', {
+ name: $('#newOrgName').value.trim(),
+ owner_email: $('#newOrgOwner').value.trim(),
+ });
+ $('#newOrgName').value = ''; $('#newOrgOwner').value = '';
+ $('#orgLinkOut').hidden = true; // never show a previous org's link
+ if (r.invite_link) {
+ $('#orgLinkValue').value = r.invite_link;
+ $('#orgLinkValue').title = 'send this to the owner; they join as the org admin';
+ $('#orgLinkOut').hidden = false;
+ $('#orgLinkValue').select();
+ }
+}, '#orgErr'));
+
+$('#orgLinkCopy').addEventListener('click', () => {
+ const f = $('#orgLinkValue');
+ f.select();
+ if (navigator.clipboard) navigator.clipboard.writeText(f.value);
+ else document.execCommand('copy');
+});
+
+load().catch((e) => showError(e.message));
diff --git a/server/tests/test_auth_store.py b/server/tests/test_auth_store.py
index 9a5cf7f..be26263 100644
--- a/server/tests/test_auth_store.py
+++ b/server/tests/test_auth_store.py
@@ -98,6 +98,14 @@ def __init__(self, db="dbidsystem00000000000000"):
def rows(self, query):
import re
flat = " ".join(query.split())
+ if "COUNT(DISTINCT email) AS members" in flat:
+ # all_orgs: one row per org_slug across users + org_memberships
+ seen = {}
+ for t in ("users", "org_memberships"):
+ for r in self.tables[t]:
+ if r.get("org_slug"):
+ seen.setdefault(r["org_slug"], set()).add(r["email"])
+ return [{"org_slug": k, "members": len(v)} for k, v in seen.items()]
table = re.search(r"FROM \S+\.public\.(\w+)", flat).group(1)
out = [dict(r) for r in self.tables[table]]
by_slug = {o["slug"]: o for o in self.tables["orgs"]}
diff --git a/server/tests/test_read_token_routes.py b/server/tests/test_read_token_routes.py
index d4a02f9..5a16ed9 100644
--- a/server/tests/test_read_token_routes.py
+++ b/server/tests/test_read_token_routes.py
@@ -52,7 +52,8 @@ def handler(cookie=None, bearer=None):
ALLOWED = ["/api/data", "/api/orgs", "/api/status", "/api/session/abc123"]
REFUSED = ["/api/admin/state", "/", "/index.html", "/admin", "/device",
"/logout", "/api/data/../api/admin/state", "/api/datax",
- "/api/session", "/api/sessions/abc"]
+ "/api/session", "/api/sessions/abc", "/system", "/api/system/state",
+ "/insights", "/api/insights/status"]
def test_a_read_token_reaches_the_read_routes():
diff --git a/server/tests/test_system.py b/server/tests/test_system.py
new file mode 100644
index 0000000..e631cc0
--- /dev/null
+++ b/server/tests/test_system.py
@@ -0,0 +1,104 @@
+#!/usr/bin/env python3
+"""The System page: platform views for the one account that is system admin.
+
+What matters is the gate, not the table: /system and /api/system/state must
+answer only a system admin, an org admin gets nothing platform-wide, and the
+nav only draws the link for the account that can use it.
+
+Run: python3 server/tests/test_system.py
+"""
+import json
+import os
+import sys
+import threading
+from http.server import ThreadingHTTPServer
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+sys.path.insert(0, HERE)
+sys.path.insert(0, os.path.dirname(HERE))
+sys.path.insert(0, os.path.dirname(os.path.dirname(HERE)))
+import server # noqa: E402
+import test_auth_store as fakes # noqa: E402
+from test_http_read_token import FakeStores, call # noqa: E402
+
+
+def boot():
+ db = fakes.StatefulSysDb()
+ auth = server.AuthStore(db, fakes.FakePool())
+ auth.create_account("ops@x.dev", "hunter2hunter2")
+ auth.create_org_for("ops@x.dev", "Platform Co")
+ auth.set_system_admin("ops@x.dev")
+ auth.create_account("ada@x.dev", "hunter2hunter2")
+ auth.create_org_for("ada@x.dev", "Acme Inc") # ada is an org admin, nothing more
+ fakes.approved_token(auth, "ops@x.dev")
+ fakes.approved_token(auth, "ada@x.dev")
+ read_token = fakes.approved_token(auth, "ops@x.dev", scope="read")
+
+ server.Handler.auth = auth
+ server.Handler.pool = fakes.FakePool()
+ server.Handler.stores = FakeStores()
+ server.Handler.token = "shared-ingest-token"
+ server.Handler.insights = None
+
+ httpd = ThreadingHTTPServer(("127.0.0.1", 0), server.Handler)
+ threading.Thread(target=httpd.serve_forever, daemon=True).start()
+ base = f"http://127.0.0.1:{httpd.server_address[1]}"
+ return httpd, base, auth, read_token
+
+
+HTTPD, BASE, AUTH, READ = boot()
+AS_OPS = {"Cookie": f"hotusage_session={AUTH.login('ops@x.dev', 'hunter2hunter2')}"}
+AS_ADA = {"Cookie": f"hotusage_session={AUTH.login('ada@x.dev', 'hunter2hunter2')}"}
+
+
+def test_the_system_admin_gets_the_page_and_every_org():
+ status, raw = call(BASE, "/system", headers=AS_OPS)
+ assert status == 200 and b'data-page="system"' in raw, (status, raw[:200])
+ status, raw = call(BASE, "/api/system/state", headers=AS_OPS)
+ d = json.loads(raw)
+ assert status == 200, (status, d)
+ slugs = sorted(o["slug"] for o in d["orgs"])
+ assert slugs == ["acme-inc", "platform-co"], slugs
+ assert d["totals"] == {"organizations": 2, "members": 2, "empty": 0}, d["totals"]
+ assert d["viewer"]["orgSlug"] == "platform-co", d["viewer"]
+ print(f"system admin: /system 200, state lists {slugs}, totals {d['totals']}")
+
+
+def test_an_org_admin_is_turned_away():
+ # urllib follows the redirect, so the page that comes back is the dashboard
+ status, raw = call(BASE, "/system", headers=AS_ADA)
+ assert status == 200 and b'data-page="system"' not in raw, (status, raw[:200])
+ status, raw = call(BASE, "/api/system/state", headers=AS_ADA)
+ assert status == 403, (status, raw[:200])
+ status, raw = call(BASE, "/api/admin/state", headers=AS_ADA)
+ d = json.loads(raw)
+ assert status == 200 and "allOrgs" not in d, "the org page must not carry the platform list"
+ print("org admin: bounced off /system, 403 on its state, nothing platform-wide on /api/admin/state")
+
+
+def test_tokens_and_strangers_get_nothing():
+ print("refused:")
+ for who, hdr in (("anonymous", None), ("read token", {"Authorization": f"Bearer {READ}"})):
+ status, raw = call(BASE, "/api/system/state", headers=hdr)
+ assert status == 401, (who, status)
+ print(f" {who}: 401")
+
+
+def test_the_nav_draws_system_only_for_the_system_admin():
+ for hdr, expect in ((AS_OPS, True), (AS_ADA, False)):
+ status, raw = call(BASE, "/api/orgs", headers=hdr)
+ d = json.loads(raw)
+ assert status == 200 and d["isSystemAdmin"] is expect, (status, d)
+ status, raw = call(BASE, "/static/nav.js")
+ assert b"'/system'" in raw and b"isSystemAdmin" in raw, "nav.js does not gate the System link"
+ status, raw = call(BASE, "/static/admin.html", headers=AS_OPS)
+ assert b"All organizations" not in raw, "the card still lives on the Organization page"
+ print("/api/orgs says isSystemAdmin true for ops, false for ada; nav gates the link on it")
+
+
+if __name__ == "__main__":
+ tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
+ for t in tests:
+ t()
+ HTTPD.shutdown()
+ print(f"\nall {len(tests)} tests passed")