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

- -

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

+ + +
+
+ + + + + + + + + +
OrganizationSlugMembersCreated
+
+
+ + + +
+ + +
+ +
+ Creating an organization provisions a database; deleting one keeps it. + Only empty organizations can be deleted, and never your own. +
+
+ + + + 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")