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>
91 lines
4.0 KiB
CSS
91 lines
4.0 KiB
CSS
/* Global app navigation drawer (see wp-sidenav.js).
|
|
|
|
An off-canvas panel rather than a pinned rail, at every width: the field view is a
|
|
centred 760px column read on a phone or a tablet in a glove, and a permanent
|
|
sidebar would either squeeze that column or hide on the one device that matters.
|
|
Overlay behaves identically everywhere, which is also one less layout to test.
|
|
|
|
Colours come from the dark app bar it hangs off (#161616 / Carbon Gray 100), not
|
|
from theme-light.css, so the drawer reads as an extension of the bar. */
|
|
|
|
.wp-navbtn{
|
|
flex: 0 0 auto; display: inline-flex; align-items: center; justify-content: center;
|
|
width: 40px; height: 40px; margin-right: 4px; padding: 0;
|
|
background: none; border: none; border-radius: 0; cursor: pointer;
|
|
color: #f4f4f4; font-family: inherit; line-height: 1;
|
|
}
|
|
.wp-navbtn:hover{ background: #353535; }
|
|
.wp-navbtn:focus-visible{ outline: 2px solid #ffffff; outline-offset: -2px; }
|
|
/* A light bar (the SOP suite / creator headers) needs the opposite ink. */
|
|
.wp-navbtn[data-bar="light"]{ color: #161616; }
|
|
.wp-navbtn[data-bar="light"]:hover{ background: #e8e8e8; }
|
|
|
|
.wp-navscrim{
|
|
position: fixed; inset: 0; z-index: 10010;
|
|
background: rgba(22,22,22,.55);
|
|
opacity: 0; transition: opacity .18s ease;
|
|
}
|
|
.wp-navscrim.is-open{ opacity: 1; }
|
|
.wp-navscrim[hidden]{ display: none; }
|
|
|
|
.wp-sidenav{
|
|
position: fixed; top: 0; left: 0; bottom: 0; z-index: 10011;
|
|
width: min(284px, 84vw);
|
|
display: flex; flex-direction: column;
|
|
background: #161616; color: #f4f4f4;
|
|
font-family: 'IBM Plex Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
transform: translateX(-100%); transition: transform .2s ease;
|
|
box-shadow: 2px 0 16px rgba(0,0,0,.4);
|
|
overflow: hidden;
|
|
}
|
|
.wp-sidenav.is-open{ transform: translateX(0); }
|
|
/* Respect a reduced-motion preference: the drawer still opens, it just doesn't slide. */
|
|
@media (prefers-reduced-motion: reduce){
|
|
.wp-sidenav, .wp-navscrim{ transition: none; }
|
|
}
|
|
|
|
.wp-sidenav-head{
|
|
display: flex; align-items: center; gap: 10px;
|
|
padding: 12px 14px; border-bottom: 1px solid #393939; flex: 0 0 auto;
|
|
}
|
|
.wp-sidenav-head .wp-logo-chip{ flex: 0 0 auto; }
|
|
.wp-sidenav-title{ font-size: 13px; font-weight: 600; line-height: 1.25; }
|
|
.wp-sidenav-title span{ display: block; font-size: 11px; font-weight: 400; color: #a8a8a8; }
|
|
.wp-sidenav-close{
|
|
margin-left: auto; width: 32px; height: 32px; padding: 0; flex: 0 0 auto;
|
|
background: none; border: none; border-radius: 0; color: #c6c6c6;
|
|
font-size: 18px; line-height: 1; cursor: pointer; font-family: inherit;
|
|
}
|
|
.wp-sidenav-close:hover{ background: #353535; color: #fff; }
|
|
|
|
.wp-sidenav-body{ flex: 1 1 auto; overflow-y: auto; padding: 6px 0 18px; }
|
|
.wp-sidenav-sect{
|
|
padding: 14px 16px 4px; font-size: 11px; font-weight: 600;
|
|
letter-spacing: .06em; text-transform: uppercase; color: #8d8d8d;
|
|
}
|
|
.wp-sidenav-link{
|
|
display: flex; align-items: center; gap: 12px; width: 100%;
|
|
/* 44px minimum: this is tapped with a work glove on. */
|
|
min-height: 44px; padding: 10px 16px;
|
|
background: none; border: none; border-left: 3px solid transparent; border-radius: 0;
|
|
color: #f4f4f4; font: inherit; font-size: 14px; text-align: left; text-decoration: none;
|
|
cursor: pointer;
|
|
}
|
|
.wp-sidenav-link:hover{ background: #353535; }
|
|
.wp-sidenav-link:focus-visible{ outline: 2px solid #ffffff; outline-offset: -2px; }
|
|
.wp-sidenav-link.is-current{ background: #262626; border-left-color: #0f62fe; font-weight: 600; }
|
|
.wp-sidenav-ico{
|
|
flex: 0 0 20px; width: 20px; text-align: center; font-size: 15px; color: #c6c6c6;
|
|
}
|
|
.wp-sidenav-link.is-current .wp-sidenav-ico{ color: #78a9ff; }
|
|
.wp-sidenav-label{ flex: 1 1 auto; min-width: 0; }
|
|
.wp-sidenav-label small{ display: block; font-size: 11.5px; font-weight: 400; color: #a8a8a8; }
|
|
|
|
.wp-sidenav-foot{
|
|
flex: 0 0 auto; border-top: 1px solid #393939; padding: 8px 0;
|
|
}
|
|
.wp-sidenav-who{
|
|
padding: 6px 16px 8px; font-size: 12px; color: #a8a8a8;
|
|
}
|
|
.wp-sidenav-who strong{ display: block; color: #f4f4f4; font-size: 13px; font-weight: 600; }
|