Files
Project-SDE-WP-Suite/html/wp-sidenav.css
n.siegfried 4ace2afb1c 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>
2026-08-05 17:36:14 -07:00

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; }