Move user administration to its own page; add Project Super User
User accounts lived in the Admin Console, which is admins-only. Project admins
need to create the accounts on their own jobs without an app admin on the phone,
so accounts move to a new User Directory page and a new role carries the right.
server/auth.py, server/app.py
New permissions role `project_super_user`, between admin and project_admin:
everything a project admin may do, plus user administration SCOPED to the
projects they hold the role on. Four limits make it safe to hand out, all
enforced server-side:
* Scope comes from projects, not the job title. It resolves per membership
(managed_project_ids), so an ordinary account can hold it on one job via
ProjectMember.role, and a super user demoted on one job administers
nobody there. No projects, no authority.
* Account-level changes (password, disable, rename, permissions, delete)
require EXCLUSIVE scope: refused when the target is also on a project the
caller does not administer, because those changes are global. The
directory renders such rows read-only with the reason.
* No admin or super-user targets, and neither role can be granted by a
super user -- that is the line that stops it becoming app-wide control.
* PUT .../projects rebuilds only the caller's own slice; memberships on
projects they do not administer are left untouched. A payload that simply
omits them must not cut someone off a job the caller cannot see.
Creating requires naming at least one of your own projects: an account with
none would be one the creator instantly cannot manage.
/api/auth/users is now scoped rather than admin-only, and carries a per-row
`manageable` verdict plus the reason. Non-managers get a contact card only --
a project user has no business reading colleagues' login history. New
/api/auth/user-scope tells the page what it may offer. Administrative
password resets are now audited; they were the one account change that left
no trace. Settings, feature flags and the auto-add rule stay admin-only.
While here: one definition of "is a user manager", derived from the managed
set. An account-role-only version disagreed with the scoped one and locked
per-project super users out of routes they were entitled to.
html/users.html, html/users.js
The directory: three renderings from one page -- admin (everything), super
user (controls per row, read-only where scope is shared), everyone else (a
read-only directory of the people on their own projects).
html/console.css, html/console-util.js
Extracted from admin.html/admin.js so both console pages share them. A
divergent jsq() is an XSS and a divergent role list offers permissions the
server refuses, so neither may exist twice.
html/wp-sidenav.{js,css}
Global nav drawer, role-gated, carrying ?project= across links. Mounted on
the field view (which had no way to anywhere) plus both console pages.
No migration: users.role is already String(20) and the new value fits.
Verified: 93 scope/gate tests, 29 live HTTP tests through the real dependency
stack, 33 static JS checks. Not verified in a browser -- no JS engine on this
machine -- so users.html and field.html want one manual load.
server/smoketest.py still fails with 401s. Pre-existing: it has no login code,
so auth_gate refuses it. Confirmed unchanged by stashing this work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
191
html/console.css
Normal file
191
html/console.css
Normal file
@@ -0,0 +1,191 @@
|
||||
/* Shared styling for the suite's dense admin pages — the Admin Console and the
|
||||
User Directory. Both are mostly tables and toolbars, which is a different job
|
||||
from the wizard pages, so they carry this sheet instead of theme-light.css's
|
||||
form-heavy one. The palette, the square corners and the type are still Carbon's,
|
||||
so the pages read as one product with the rest of the suite.
|
||||
|
||||
Two scales do all the spacing and all the control sizing; nothing that uses this
|
||||
sheet should invent its own. Page-specific rules (per-ID scroll boxes, column
|
||||
exceptions) stay in the page that owns them.
|
||||
|
||||
══ TOKENS ══════════════════════════════════════════════════════════════════ */
|
||||
:root{ --bg:#f4f4f4; --surface:#fff; --border:#e0e0e0; --border-strong:#8d8d8d; --text:#161616;
|
||||
--muted:#525252; --dim:#8d8d8d; --accent:#0f62fe; --accent-hover:#0353e9; --accent-soft:#edf5ff;
|
||||
--green:#198038; --green-bg:#defbe6;
|
||||
--red:#da1e28; --red-bg:#fff1f1; --amber:#8e6a00; --amber-bg:#fdf6dd;
|
||||
--head-bg:#f4f4f4; --zebra:#fafafa; --row-hover:#eef0f2;
|
||||
--mono:'IBM Plex Mono','Cascadia Mono',Consolas,monospace;
|
||||
--s1:4px; --s2:8px; --s3:12px; --s4:16px; --s5:20px; --s6:28px;
|
||||
--ctl:32px; /* every button / input / select that sits in a form row */
|
||||
--ctl-sm:26px; } /* every control that sits inside a table cell */
|
||||
*{ box-sizing:border-box; }
|
||||
body{ margin:0; font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; background:var(--bg); color:var(--text); }
|
||||
|
||||
/* ══ PAGE ══════════════════════════════════════════════════════════════════════
|
||||
1240px, not 860: the user table is nine columns wide and at 860 it spilled
|
||||
straight out of its own white card. Wide enough for that table, still a
|
||||
readable measure for the prose, which is capped separately. */
|
||||
.wrap{ max-width:1240px; margin:0 auto; padding:var(--s6) var(--s5) 80px; }
|
||||
h1{ font-size:20px; line-height:1.2; margin:0 0 2px; }
|
||||
.sub{ color:var(--muted); font-size:13px; line-height:1.5; margin:0 0 var(--s3); max-width:96ch; }
|
||||
a.home{ color:var(--accent); font-size:13px; text-decoration:none; white-space:nowrap; }
|
||||
a.home:hover{ text-decoration:underline; }
|
||||
|
||||
/* ══ CARDS ═════════════════════════════════════════════════════════════════════ */
|
||||
.card{ background:var(--surface); border:1px solid var(--border); border-radius:0;
|
||||
padding:var(--s4) var(--s5) var(--s5); margin-bottom:var(--s4); }
|
||||
/* One card header everywhere: small uppercase accent label on a hairline. The
|
||||
scripts also emit h2 for sub-sections inside a card with an inline margin-top —
|
||||
the same treatment reads correctly as a divider there, so both get it. */
|
||||
.card h2{ font-size:12px; font-weight:600; letter-spacing:.08em; text-transform:uppercase;
|
||||
color:var(--accent); margin:0 0 var(--s3); padding-bottom:var(--s2); border-bottom:1px solid var(--border); }
|
||||
.wrap code{ font-family:var(--mono); font-size:.92em; background:var(--bg); padding:1px 4px; }
|
||||
|
||||
/* ══ CONTROLS ══════════════════════════════════════════════════════════════════
|
||||
Every button, input and select in a form row is exactly --ctl tall, so a
|
||||
toolbar is one clean band instead of a ragged one. */
|
||||
button{ font:inherit; font-size:13px; font-weight:600; line-height:1; white-space:nowrap;
|
||||
height:var(--ctl); padding:0 var(--s3); border-radius:0; cursor:pointer;
|
||||
border:1px solid var(--border-strong); background:#fff; color:var(--text); }
|
||||
button:hover{ border-color:var(--accent); color:var(--accent); }
|
||||
button:focus-visible{ outline:2px solid var(--accent); outline-offset:-3px; }
|
||||
button:disabled, button:disabled:hover{ color:var(--dim); border-color:var(--border); background:#fff; cursor:default; }
|
||||
button.primary{ background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
button.primary:hover{ background:var(--accent-hover); border-color:var(--accent-hover); color:#fff; }
|
||||
button.danger{ border-color:var(--red); color:var(--red); }
|
||||
button.danger:hover{ background:var(--red-bg); border-color:var(--red); color:var(--red); }
|
||||
.row{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||
/* The filter / search / button strip at the top of a card. */
|
||||
.toolbar{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; margin:0 0 var(--s3); }
|
||||
.toolbar + .banner{ margin-top:0; }
|
||||
.urow{ display:flex; gap:var(--s2); flex-wrap:wrap; align-items:center; }
|
||||
/* Checkboxes are excluded: they are drawn by the platform and want none of a
|
||||
text field's height, padding or border. */
|
||||
.toolbar input:not([type=checkbox]), .toolbar select,
|
||||
.urow input:not([type=checkbox]), .urow select{
|
||||
height:var(--ctl); padding:0 var(--s2); font:inherit; font-size:13px; line-height:normal;
|
||||
border:1px solid var(--border-strong); border-radius:0; background:#fff; color:var(--text); }
|
||||
.toolbar select, .urow select{ cursor:pointer; padding-right:var(--s1); }
|
||||
.toolbar input:focus-visible, .toolbar select:focus-visible,
|
||||
.urow input:focus-visible, .urow select:focus-visible{ outline:2px solid var(--accent); outline-offset:-2px; }
|
||||
.toolbar > input{ flex:1 1 240px; min-width:150px; }
|
||||
.urow input:not([type=checkbox]){ flex:1 1 140px; min-width:0; }
|
||||
/* Inline checkbox + label, sized to sit on the same line as the buttons. */
|
||||
.chk{ display:inline-flex; align-items:center; gap:var(--s2); height:var(--ctl); padding:0 var(--s1);
|
||||
font-size:13px; color:var(--muted); white-space:nowrap; cursor:pointer; }
|
||||
.chk input{ width:16px; height:16px; margin:0; accent-color:var(--accent); cursor:pointer; }
|
||||
|
||||
/* ══ FEEDBACK: banners, notes, console output, key/value ════════════════════════ */
|
||||
.banner{ margin:var(--s3) 0 0; padding:9px var(--s3); border-radius:0; font-size:13px; font-weight:600;
|
||||
line-height:1.4; border:1px solid var(--border); border-left:3px solid var(--border-strong);
|
||||
background:var(--surface); color:var(--text); }
|
||||
.banner.ok{ background:var(--green-bg); color:var(--green); border-color:#a7f0ba; border-left-color:var(--green); }
|
||||
.banner.bad{ background:var(--red-bg); color:var(--red); border-color:#ffd7d9; border-left-color:var(--red); }
|
||||
.banner.warn{ background:var(--amber-bg); color:var(--amber); border-color:#fddc69; border-left-color:var(--amber); }
|
||||
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
|
||||
and the boxes the scripts fill are themselves .note — their primary toggle
|
||||
labels inherit this colour. */
|
||||
.note{ font-size:12px; line-height:1.55; color:var(--muted); margin-top:var(--s2); }
|
||||
.note strong, .note em{ color:var(--text); }
|
||||
pre.out{ background:#0f1525; color:#d7e0f5; border-radius:0; padding:var(--s3) var(--s4); font-family:var(--mono);
|
||||
font-size:12px; line-height:1.55; white-space:pre-wrap; max-height:340px; overflow:auto; margin:var(--s3) 0 0; }
|
||||
pre.out .p{ color:#56d364; font-weight:700; } pre.out .f{ color:#ff7b72; font-weight:700; }
|
||||
table.kv{ border-collapse:collapse; font-size:13px; margin-top:var(--s2); }
|
||||
table.kv th{ text-align:left; padding:var(--s1) var(--s5) var(--s1) 0; color:var(--muted); font-weight:600; white-space:nowrap; }
|
||||
table.kv td{ padding:var(--s1) 0; font-variant-numeric:tabular-nums; font-weight:700; color:var(--text); }
|
||||
|
||||
/* ══ DATA TABLES ═══════════════════════════════════════════════════════════════
|
||||
table.users is the name admin.js already emits; table.grid is the same object
|
||||
under the shared name. One rule set serves both, so existing markup picks up the
|
||||
dense styling without being rewritten. border-collapse is separate rather than
|
||||
collapse because a collapsed border does not travel with a sticky header. */
|
||||
table.grid, table.users{ width:100%; border-collapse:separate; border-spacing:0;
|
||||
font-size:13px; color:var(--text); background:var(--surface); }
|
||||
table.grid th, table.users th{ position:sticky; top:0; z-index:2; background:var(--head-bg);
|
||||
text-align:left; padding:var(--s2) var(--s3); white-space:nowrap;
|
||||
font-size:11px; font-weight:600; letter-spacing:.04em; text-transform:uppercase; color:var(--muted);
|
||||
box-shadow:inset 0 -1px 0 var(--border); }
|
||||
/* Cells never wrap: a wrapped cell turns one user into a 100px tall band and the
|
||||
table stops reading as rows. Anything genuinely long truncates (.ell) or is
|
||||
exempted by name in the page that owns the table. */
|
||||
table.grid td, table.users td{ padding:var(--s1) var(--s3); border-bottom:1px solid var(--border);
|
||||
vertical-align:middle; white-space:nowrap; }
|
||||
table.grid tbody tr:last-child td, table.users tbody tr:last-child td{ border-bottom:none; }
|
||||
table.grid tbody tr:nth-child(even) td, table.users tbody tr:nth-child(even) td{ background:var(--zebra); }
|
||||
/* A neutral hover, not --accent-soft: that is .tag.admin's fill, and an "all
|
||||
projects" pill sitting on its own colour disappears the moment you hover it. */
|
||||
table.grid tbody tr:hover td, table.users tbody tr:hover td{ background:var(--row-hover); }
|
||||
/* A row for an account this caller may see but not change. Dimmed as a whole so
|
||||
the disabled controls aren't the only clue. */
|
||||
table.grid tbody tr.is-locked td, table.users tbody tr.is-locked td{ color:var(--muted); }
|
||||
/* Truncation has to hang off a block INSIDE the cell. max-width on a <td> is
|
||||
advisory under table-layout:auto — the cell just grows to fit and the ellipsis
|
||||
never appears, which is the usual reason this trick looks like it works in the
|
||||
stylesheet and doesn't on the page. The scripts emit <td class="ell"><span>. */
|
||||
.ell{ max-width:240px; }
|
||||
.ell > span{ display:block; max-width:240px; overflow:hidden; text-overflow:ellipsis;
|
||||
white-space:nowrap; }
|
||||
/* Every action cell the scripts render is a .cellactions, and it must not wrap:
|
||||
unwrapped, the three buttons stack and the row grows fourfold. */
|
||||
.cellactions{ display:flex; flex-wrap:nowrap; align-items:center; gap:var(--s1); white-space:nowrap; }
|
||||
/* Controls that live in a cell are one step smaller, which is what keeps a row at
|
||||
~34px instead of ~100px. .chk is form-row sized by default, so it needs saying
|
||||
again here or checkbox rows stand 6px taller than the rest. */
|
||||
button.mini{ height:var(--ctl-sm); padding:0 var(--s2); font-size:12px; }
|
||||
table.grid td .chk, table.users td .chk{ height:var(--ctl-sm); }
|
||||
select.role-select{ height:var(--ctl-sm); max-width:170px; padding:0 var(--s1) 0 var(--s2);
|
||||
font:inherit; font-size:12px; border:1px solid var(--border-strong); border-radius:0;
|
||||
background:#fff; color:var(--text); cursor:pointer; }
|
||||
select.role-select:hover{ border-color:var(--accent); }
|
||||
select.role-select.is-admin{ color:var(--accent); border-color:var(--accent); font-weight:600; }
|
||||
select.role-select:disabled{ color:var(--dim); border-color:var(--border); background:var(--bg); cursor:default; }
|
||||
.tag{ display:inline-block; padding:1px 8px; border-radius:11px; font-size:11px; font-weight:600;
|
||||
line-height:1.55; white-space:nowrap; vertical-align:middle; }
|
||||
.tag.admin{ background:var(--accent-soft); color:var(--accent); }
|
||||
.tag.super{ background:#e8daff; color:#6929c4; }
|
||||
.tag.user{ background:#e8e8e8; color:var(--muted); }
|
||||
.tag.on{ background:var(--green-bg); color:var(--green); }
|
||||
.tag.off{ background:var(--red-bg); color:var(--red); }
|
||||
.tag.archived{ background:var(--amber-bg); color:var(--amber); }
|
||||
.me-tag{ font-size:11px; color:var(--dim); margin-left:6px; white-space:nowrap; }
|
||||
|
||||
/* A wide table scrolls inside its own box so the page never scrolls sideways, and
|
||||
the capped height is what gives the sticky header something to do. */
|
||||
.tscroll{ overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||
|
||||
/* ══ MODALS ════════════════════════════════════════════════════════════════════
|
||||
The project-access dialog, shared by both pages. */
|
||||
.modal-ov{ position:fixed; inset:0; background:rgba(20,30,50,.5); display:flex; align-items:center;
|
||||
justify-content:center; z-index:10002; padding:var(--s5); }
|
||||
.modal-box{ background:var(--surface); border-radius:0; max-width:660px; width:100%; max-height:82vh;
|
||||
display:flex; flex-direction:column; overflow:hidden; box-shadow:0 12px 40px rgba(20,30,50,.3); }
|
||||
.modal-head{ padding:var(--s3) var(--s4); border-bottom:1px solid var(--border); font-weight:700; }
|
||||
.modal-body{ padding:var(--s3) var(--s4); overflow:auto; }
|
||||
.modal-foot{ padding:var(--s3) var(--s4); border-top:1px solid var(--border);
|
||||
display:flex; gap:var(--s2); justify-content:flex-end; }
|
||||
.pickrow{ display:flex; align-items:center; gap:var(--s3); padding:var(--s2) var(--s1);
|
||||
border-bottom:1px solid var(--border); font-size:13px; }
|
||||
.pickrow:last-child{ border-bottom:none; }
|
||||
.pickrow > label{ display:flex; align-items:center; gap:var(--s2); flex:1; min-width:0; cursor:pointer; }
|
||||
.pickrow > label > span{ overflow:hidden; text-overflow:ellipsis; }
|
||||
|
||||
/* ══ GATES & WARNINGS ══════════════════════════════════════════════════════════ */
|
||||
.gate-overlay{ position:fixed; inset:0; background:var(--bg); display:flex; align-items:center; justify-content:center; padding:var(--s5); z-index:9999; }
|
||||
.gate-box{ background:var(--surface); border:1px solid var(--border); border-radius:0; padding:var(--s6); max-width:380px; width:100%; box-shadow:0 8px 30px rgba(20,30,50,.12); }
|
||||
.gate-box h2{ margin:0 0 var(--s1); padding:0; border:0; font-size:17px; text-transform:none; letter-spacing:0; color:var(--text); }
|
||||
.gate-box p{ color:var(--muted); font-size:13px; margin:0 0 var(--s4); }
|
||||
.gate-msg{ color:var(--red); font-size:12px; min-height:16px; margin-bottom:var(--s2); }
|
||||
.secwarn{ background:var(--amber-bg); color:var(--amber); border:1px solid var(--amber); border-radius:0; padding:9px 13px; font-size:12px; margin-bottom:var(--s4); }
|
||||
|
||||
/* ══ NARROW SCREENS ════════════════════════════════════════════════════════════
|
||||
The page itself must never scroll sideways; the wide tables scroll inside their
|
||||
own box instead, and there they get the full page height to do it. */
|
||||
@media (max-width:900px){
|
||||
.wrap{ padding:var(--s4) var(--s3) 60px; }
|
||||
.card{ padding:var(--s3) var(--s4) var(--s4); }
|
||||
.toolbar > input{ flex:1 1 100%; }
|
||||
.tscroll{ max-height:none; }
|
||||
}
|
||||
@media (max-width:620px){
|
||||
.urow input, .urow select, .urow button{ flex:1 1 100%; }
|
||||
}
|
||||
Reference in New Issue
Block a user