Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/operating.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
45 changes: 38 additions & 7 deletions server/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: Add the org_slug check that /admin has at line 2947, before the system admin check. (not blocking)

A system admin with no organization gets system.html from this route. /api/system/state then answers 409 from the shared org_slug gate at line 3035, so the page shows only an error banner. The fix is to redirect to /setup?next=... here, the same way /admin does.

self._redirect("/")
return
self._static("system.html")
return
if path == "/device":
q = parse_qs(parsed.query)
viewer = self._viewer()
Expand Down Expand Up @@ -3086,16 +3101,32 @@ 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
# two waves rather than one: what the viewer may see first,
# 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},
Expand All @@ -3107,17 +3138,17 @@ 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":
slugs = sorted(self.auth.memberships(viewer["email"]))
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/"):
Expand Down
6 changes: 6 additions & 0 deletions server/static/admin.css
Original file line number Diff line number Diff line change
Expand Up @@ -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); }
33 changes: 0 additions & 33 deletions server/static/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -53,39 +53,6 @@ <h2>Members</h2>
</div>
</div>

<div class="card sysonly" hidden>
<div class="card-head">
<h2>All organizations</h2>
<span class="spacer"></span>
<span class="flabel">System admin</span>
</div>
<div class="scroller">
<table class="admintable" id="orgs">
<thead>
<tr class="head"><th>Organization</th><th>Slug</th><th>Members</th><th>Created</th><th></th></tr>
</thead>
<tbody>
<tr class="placeholder"><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td></td></tr>
<tr class="placeholder"><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td></td></tr>
<tr class="placeholder"><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td></td></tr>
</tbody>
</table>
</div>
<div class="cardfoot">
<input id="newOrgName" type="text" placeholder="Organization name"
aria-label="Organization name">
<input id="newOrgOwner" type="email" placeholder="Owner email (optional)"
aria-label="Owner email (optional)">
<button class="refresh primary" id="orgCreate" type="button">Create organization</button>
</div>
<div class="modal-err" id="orgErr" hidden></div>
<div class="linkout" id="orgLinkOut" hidden>
<input id="orgLinkValue" readonly aria-label="Invite link for the new organization's owner">
<button class="refresh" id="orgLinkCopy" type="button">Copy</button>
</div>

</div>

<div class="card adminonly">
<div class="card-head"><h2>Pending invites</h2></div>
<div class="scroller">
Expand Down
42 changes: 3 additions & 39 deletions server/static/admin.js
Original file line number Diff line number Diff line change
@@ -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.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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();
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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));
14 changes: 13 additions & 1 deletion server/static/nav.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
73 changes: 73 additions & 0 deletions server/static/system.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>hototel - system</title>
<link rel="stylesheet" href="/static/app.css">
<link rel="icon" href="/static/icon.svg">
<link rel="stylesheet" href="/static/admin.css">
</head>
<body data-page="system">
<header class="top"></header>
<div class="wrap">
<div class="modal-err" id="err" hidden></div>

<!-- Platform-level views: what the whole server holds, across every
organization. Only the system admin reaches this page; the server
sends everyone else to their dashboard. The Organization page stays
about one team. -->
<div class="card">
<div class="card-head">
<h2>Platform</h2>
<span class="spacer"></span>
<span class="flabel">System admin</span>
</div>
<div class="totals" id="totals">
<div class="total"><span class="num" id="totOrgs">&nbsp;</span><span class="lbl">organizations</span></div>
<div class="total"><span class="num" id="totMembers">&nbsp;</span><span class="lbl">members</span></div>
<div class="total"><span class="num" id="totEmpty">&nbsp;</span><span class="lbl">empty organizations</span></div>
</div>
</div>

<div class="card">
<div class="card-head">
<h2>All organizations</h2>
<span class="spacer"></span>
<span class="flabel" id="orgCount"></span>
</div>
<div class="scroller">
<table class="admintable" id="orgs">
<thead>
<tr class="head"><th>Organization</th><th>Slug</th><th>Members</th><th>Created</th><th></th></tr>
</thead>
<tbody>
<tr class="placeholder"><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td></td></tr>
<tr class="placeholder"><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td></td></tr>
<tr class="placeholder"><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td><div class="skel skel-cell"></div></td><td></td></tr>
</tbody>
</table>
</div>
<div class="cardfoot">
<input id="newOrgName" type="text" placeholder="Organization name"
aria-label="Organization name">
<input id="newOrgOwner" type="email" placeholder="Owner email (optional)"
aria-label="Owner email (optional)">
<button class="refresh primary" id="orgCreate" type="button">Create organization</button>
</div>
<div class="modal-err" id="orgErr" hidden></div>
<div class="linkout" id="orgLinkOut" hidden>
<input id="orgLinkValue" readonly aria-label="Invite link for the new organization's owner">
<button class="refresh" id="orgLinkCopy" type="button">Copy</button>
</div>
</div>

<footer class="foot">
Creating an organization provisions a database; deleting one keeps it.
Only empty organizations can be deleted, and never your own.
</footer>
</div>
<script src="/static/nav.js"></script>
<script src="/static/system.js"></script>
</body>
</html>
Loading
Loading