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:
244
html/admin.html
244
html/admin.html
@@ -13,198 +13,45 @@
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="console.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<style>
|
||||
/* ══ TOKENS ══════════════════════════════════════════════════════════════
|
||||
The console is the only page in the suite that is mostly dense tables, so
|
||||
it carries its own sheet. The palette, the square corners and the type are
|
||||
Carbon's — the same ones theme-light.css sets — so it still reads as one
|
||||
product with the rest of the suite. Two scales do all the spacing and all
|
||||
the control sizing; nothing in here should invent its own. */
|
||||
: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-specific only — the tokens, cards, controls, tables, banners and modal
|
||||
live in console.css, shared with the User Directory. What stays here is what
|
||||
only this page has: the per-card scroll boxes admin.js paints tables into, the
|
||||
column exceptions for those tables, and the admins-only notice.
|
||||
|
||||
/* ══ 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; }
|
||||
These are addressed by ID because admin.js emits the tables without per-cell
|
||||
classes. */
|
||||
|
||||
/* ══ 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.
|
||||
admin.js also emits h2 for sub-sections inside a card (Localization
|
||||
defaults, Step views, Actions) 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; }
|
||||
/* These start life as empty divs that admin.js fills on demand, so they only earn
|
||||
their gap once they are actually saying something. */
|
||||
#projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
|
||||
/* ══ 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); }
|
||||
/* These three start life as empty divs that admin.js fills on demand, so they
|
||||
only earn their gap once they are actually saying something. */
|
||||
#users-banner:not(:empty), #projects-banner:not(:empty), #defmem-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
/* --muted, not --dim: #8d8d8d on white is 3.3:1, under the 4.5:1 floor at 12px,
|
||||
and #features-box / #settings-box 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 further down. */
|
||||
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); }
|
||||
/* 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. admin.js emits <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 admin.js renders is a .cellactions, and it must not wrap:
|
||||
unwrapped, the three buttons stack and the row grows fourfold — which is what
|
||||
the console looked like before this pass. */
|
||||
.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 the default-members 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; }
|
||||
.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.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. Every
|
||||
container admin.js paints a table into is one, so a sticky header always has
|
||||
a scrollport of its own rather than sliding up behind the app bar. */
|
||||
.tscroll, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
|
||||
#projects-table, #defmem-table{
|
||||
/* Every container admin.js paints a table into is a scrollport of its own, so a
|
||||
sticky header always has something to stick to rather than sliding up behind
|
||||
the app bar. Same rule as console.css's .tscroll. */
|
||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||
overflow:auto; max-height:min(70vh,640px); overscroll-behavior:contain; }
|
||||
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside
|
||||
so one table never ends up with two scrollbars. */
|
||||
#users-table:has(.tscroll), #comments-admin:has(.tscroll), #audit-admin:has(.tscroll),
|
||||
#notif-box:has(.tscroll), #usage-admin:has(.tscroll), #projects-table:has(.tscroll),
|
||||
#defmem-table:has(.tscroll){
|
||||
/* If admin.js wraps its table in its own .tscroll, the outer box steps aside so
|
||||
one table never ends up with two scrollbars. */
|
||||
#comments-admin:has(.tscroll), #audit-admin:has(.tscroll), #notif-box:has(.tscroll),
|
||||
#usage-admin:has(.tscroll), #projects-table:has(.tscroll), #defmem-table:has(.tscroll){
|
||||
overflow:visible; max-height:none; }
|
||||
|
||||
/* Column exceptions, addressed by card because admin.js emits these tables
|
||||
without per-cell classes. Email is the one user cell long enough to stretch
|
||||
a row, so it truncates; comment text and audit detail are the two columns
|
||||
you are actually here to read, so they wrap inside a sane width instead. */
|
||||
#users-table table.users td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
|
||||
#comments-admin table.users td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
|
||||
#audit-admin table.users td:nth-child(6){ white-space:normal; max-width:420px; }
|
||||
/* Comment text and audit detail are the two columns you are actually here to
|
||||
read, so they wrap inside a sane width instead of truncating. */
|
||||
#comments-admin table td:nth-child(5){ white-space:normal; min-width:260px; max-width:640px; }
|
||||
#audit-admin table td:nth-child(6){ white-space:normal; max-width:420px; }
|
||||
|
||||
/* ══ 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-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px; border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||
.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); }
|
||||
/* The denial notice is a sentence, not a table — don't stretch it to 1240px. */
|
||||
#admin-denied .card{ max-width:560px; }
|
||||
.gate-box input{ width:100%; height:var(--ctl); padding:0 var(--s3); font:inherit; font-size:14px;
|
||||
border:1px solid var(--border-strong); border-radius:0; margin-bottom:var(--s3); }
|
||||
|
||||
/* ══ 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, #users-table, #comments-admin, #audit-admin, #notif-box, #usage-admin,
|
||||
#projects-table, #defmem-table{ max-height:none; }
|
||||
}
|
||||
@media (max-width:620px){
|
||||
.urow input, .urow select, .urow button{ flex:1 1 100%; }
|
||||
#comments-admin, #audit-admin, #notif-box, #usage-admin, #projects-table, #defmem-table{
|
||||
max-height:none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -241,29 +88,14 @@
|
||||
<div class="banner" id="health-banner">—</div>
|
||||
</div>
|
||||
|
||||
<!-- USER ADMINISTRATION -->
|
||||
<!-- USER ADMINISTRATION — moved out to its own page -->
|
||||
<div class="card">
|
||||
<h2>User administration</h2>
|
||||
<div class="sub">Login accounts for the portal. Requires an <strong>admin</strong> role on your own account.</div>
|
||||
<div class="toolbar"><button onclick="loadUsers()">Refresh users</button></div>
|
||||
<div id="users-banner"></div>
|
||||
<div id="users-table"></div>
|
||||
|
||||
<h2 style="margin-top:var(--s6)">Add a user</h2>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role" title="Permissions — what this account may do">
|
||||
<option value="project_user">Project User</option>
|
||||
<option value="project_admin">Project Admin</option>
|
||||
<option value="admin">Administrator</option>
|
||||
</select>
|
||||
<select id="nu-project-role" title="Job function on the project"></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||
<button class="primary" onclick="createUser()">Create user</button>
|
||||
</div>
|
||||
<div id="users-create-msg" class="note"></div>
|
||||
<h2>User accounts</h2>
|
||||
<div class="sub">Login accounts, permissions and project access now live on the
|
||||
<strong>User Directory</strong> page. They moved because user administration is no longer
|
||||
admin-only: a <strong>Project Super User</strong> creates and manages the accounts on the
|
||||
projects they administer, and they must never be sent through this console to do it.</div>
|
||||
<div class="toolbar"><a class="home" href="users.html"><button class="primary">Open the User Directory →</button></a></div>
|
||||
</div>
|
||||
|
||||
<!-- PROJECTS (ARCHIVE / UNARCHIVE) -->
|
||||
@@ -286,8 +118,10 @@
|
||||
<h2>Default members on new projects</h2>
|
||||
<div class="sub">Everyone flagged here is added automatically to every project created from now on,
|
||||
with the role chosen here. It does not touch projects that already exist — for those, use
|
||||
<strong>Project access</strong> in the user table above. Administrators are listed with nothing
|
||||
to set: they already reach every project.</div>
|
||||
<strong>Project access</strong> on the <a class="home" href="users.html">User Directory</a>.
|
||||
Administrators are listed with nothing to set: they already reach every project. This card stays
|
||||
in the console because it is a rule about <em>every</em> future project, including the ones a
|
||||
Project Super User has no part in — so only an admin sets it.</div>
|
||||
<div class="toolbar"><button onclick="loadDefaultMembers()">Refresh</button></div>
|
||||
<div id="defmem-banner"></div>
|
||||
<div id="defmem-table"><div class="note">Click “Refresh” to load.</div></div>
|
||||
@@ -377,7 +211,9 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="console-util.js"></script>
|
||||
<script src="admin.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
363
html/admin.js
363
html/admin.js
@@ -4,14 +4,20 @@
|
||||
ACCESS: the console is gated on the signed-in user's ROLE. auth-guard.js
|
||||
already requires a login (redirecting to login.html otherwise) and publishes
|
||||
window.WP_USER; here we show the console only when that user is an admin, and
|
||||
show an "Admins only" notice otherwise. Every user-management API is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation. */
|
||||
show an "Admins only" notice otherwise. Every API this page calls is also
|
||||
enforced as admin-only server-side, so this is a real gate, not obfuscation.
|
||||
|
||||
USER ACCOUNTS LIVE ON users.html, not here. They moved when the Project Super
|
||||
User role arrived: administering users is no longer an admin-only act, so the
|
||||
page that does it can't be behind an admins-only gate. What stays here is what
|
||||
genuinely is app-wide and admin-only — settings, feature flags, diagnostics,
|
||||
project archiving, and the default-member rule for future projects.
|
||||
|
||||
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
|
||||
|
||||
function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
fillProjectRoleOptions();
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadProjects();
|
||||
loadDefaultMembers();
|
||||
loadSettings();
|
||||
@@ -24,18 +30,6 @@ function showDenied(){
|
||||
document.getElementById('admin-denied').style.display='';
|
||||
}
|
||||
|
||||
// ── api helper ──────────────────────────────────────────────────────────────
|
||||
async function api(method, path, body){
|
||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||
try {
|
||||
const r = await fetch(path, opt);
|
||||
const t = await r.text();
|
||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||
return { status:r.status, json };
|
||||
} catch(e){ return { status:0, json:String(e) }; }
|
||||
}
|
||||
|
||||
// ── connectivity ──────────────────────────────────────────────────────────────
|
||||
async function checkHealth(){
|
||||
const b = document.getElementById('health-banner');
|
||||
@@ -165,317 +159,6 @@ async function cleanDemo(){
|
||||
snapshot();
|
||||
}
|
||||
|
||||
// ── user administration ────────────────────────────────────────────────────────
|
||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
|
||||
// for the JS string literal it lands in, and again for the HTML attribute carrying
|
||||
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
|
||||
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
|
||||
// survives, and the browser decodes the entities before the JS parser runs.
|
||||
//
|
||||
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
|
||||
// value containing a backslash: the \ we add is itself escaped by the stored one,
|
||||
// the quote closes the literal, and everything after it runs as code. Project names
|
||||
// and full names are free text that any signed-in user can write, so that is a real
|
||||
// path from a project_user to whatever an admin's session can do. Use jsq() for
|
||||
// EVERY value that lands inside an inline handler.
|
||||
function jsq(v){
|
||||
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
|
||||
}
|
||||
|
||||
async function currentUserId(){
|
||||
if(window.WP_USER && window.WP_USER.id) return window.WP_USER.id;
|
||||
const { status, json } = await api('GET','/api/auth/me');
|
||||
return (status===200 && json && json.user) ? json.user.id : null;
|
||||
}
|
||||
|
||||
async function loadUsers(){
|
||||
const banner=document.getElementById('users-banner');
|
||||
const wrap=document.getElementById('users-table');
|
||||
banner.className='banner'; banner.textContent='Loading…'; banner.style.display='';
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status===403){
|
||||
banner.className='banner bad';
|
||||
banner.textContent='❌ Your account is not an admin, so you can’t manage users. Ask an admin, or use the CLI: python -m server.manage_users';
|
||||
wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status===401){
|
||||
banner.className='banner bad'; banner.textContent='❌ Not signed in. Reload and log in again.'; wrap.innerHTML=''; return;
|
||||
}
|
||||
if(status!==200 || !Array.isArray(json)){
|
||||
banner.className='banner bad'; banner.textContent='❌ Could not load users (HTTP '+status+').'; wrap.innerHTML=''; return;
|
||||
}
|
||||
banner.style.display='none';
|
||||
const meId = await currentUserId();
|
||||
renderUsers(json, meId);
|
||||
// Fill in the project-access counts, then repaint that column.
|
||||
await loadProjectCounts(json);
|
||||
renderUsers(json, meId);
|
||||
}
|
||||
|
||||
// Permissions roles (what an account may do) — mirrors auth.ROLES on the server.
|
||||
const PERM_ROLES = ['admin','project_admin','project_user'];
|
||||
const PERM_LABELS = { admin:'Administrator', project_admin:'Project Admin', project_user:'Project User' };
|
||||
// Job functions on a project. Descriptive only — no permissions attached.
|
||||
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
|
||||
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
|
||||
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
|
||||
'Field Technician'];
|
||||
// Accounts created before permissions roles existed carry the legacy value 'user'.
|
||||
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
|
||||
|
||||
function fillProjectRoleOptions(){
|
||||
const sel=document.getElementById('nu-project-role'); if(!sel) return;
|
||||
sel.innerHTML='<option value="">Project role…</option>'+
|
||||
PROJECT_ROLES.map(r=>'<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||||
}
|
||||
|
||||
// Per-user project access gets its own column: it was buried among the action
|
||||
// buttons, which is exactly where you'd fail to find "which projects can this
|
||||
// person see, and what may they do there".
|
||||
let _userProjectCounts = {}; // user id -> number of assigned projects
|
||||
|
||||
function projAccessCell(u){
|
||||
const uname = jsq(u.username);
|
||||
if(normRole(u.role) === 'admin'){
|
||||
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
|
||||
}
|
||||
const n = _userProjectCounts[u.id];
|
||||
const label = (n === undefined) ? 'Projects…'
|
||||
: (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
|
||||
return '<button class="mini' + (n === 0 ? ' danger' : '') +
|
||||
'" onclick="manageProjects(\'' + jsq(u.id) + '\',\'' + uname + '\')"' +
|
||||
' title="Choose which projects this user can access, and their role on each">' +
|
||||
label + '</button>';
|
||||
}
|
||||
|
||||
// A project's role dropdown only matters while that project is ticked.
|
||||
function projRowToggled(cb){
|
||||
const row = cb.closest('div');
|
||||
const sel = row && row.querySelector('select');
|
||||
if(sel) sel.disabled = !cb.checked;
|
||||
}
|
||||
|
||||
// Counts for that column. One call per user, but only for non-admins and only on a
|
||||
// refresh — the admin console is not a hot path.
|
||||
async function loadProjectCounts(list){
|
||||
const targets = (list || []).filter(u => normRole(u.role) !== 'admin');
|
||||
await Promise.all(targets.map(async u => {
|
||||
const { status, json } = await api('GET','/api/auth/users/'+u.id+'/projects');
|
||||
if(status === 200 && json) _userProjectCounts[u.id] = (json.assigned || []).length;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderUsers(list, meId){
|
||||
const wrap=document.getElementById('users-table');
|
||||
if(!list.length){ wrap.innerHTML='<div class="note">No users yet.</div>'; return; }
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
let rows = list.map(u=>{
|
||||
const me = u.id===meId;
|
||||
const active = u.is_active;
|
||||
const disableBtn = me
|
||||
? '<button class="mini" disabled title="You can’t disable yourself">—</button>'
|
||||
: '<button class="mini" onclick="toggleActive(\''+jsq(u.id)+'\','+(!active)+')">'+(active?'Disable':'Enable')+'</button>';
|
||||
const delBtn = me
|
||||
? ''
|
||||
: '<button class="mini danger" onclick="deleteUser(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')">Delete</button>';
|
||||
// Role can be changed at any time via an inline dropdown. Your own row is
|
||||
// locked (a shown-as-tag) so an admin can't accidentally demote themselves.
|
||||
const escUname = jsq(u.username);
|
||||
const escUid = jsq(u.id);
|
||||
// PERMISSIONS role — what the account may do. Your own row is locked (shown as
|
||||
// a tag) so an admin can't accidentally demote themselves.
|
||||
const role = normRole(u.role);
|
||||
const roleCell = me
|
||||
? '<span class="tag '+(role==='admin'?'admin':'user')+'">'+uesc(PERM_LABELS[role]||role)+'</span><span class="me-tag">locked</span>'
|
||||
: '<select class="role-select'+(role==='admin'?' is-admin':'')+'" title="Change what this account may do" onchange="changeRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||
PERM_ROLES.map(function(r){
|
||||
return '<option value="'+r+'"'+(role===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>';
|
||||
}).join('')+
|
||||
'</select>';
|
||||
// PROJECT role — the person's job function. Descriptive only; grants nothing.
|
||||
const pr = u.project_role || '';
|
||||
const projRoleCell =
|
||||
'<select class="role-select" title="Job function on the project" onchange="changeProjectRole(\''+escUid+'\',this.value,\''+escUname+'\')">'+
|
||||
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
||||
PROJECT_ROLES.map(function(r){
|
||||
return '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>';
|
||||
}).join('')+
|
||||
// Keep a title that isn't on the list (set via the API or an older record).
|
||||
(pr && PROJECT_ROLES.indexOf(pr)<0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
|
||||
'</select>';
|
||||
return '<tr>'+
|
||||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||
// The address is truncated with the full value on the title: a long one used
|
||||
// to wrap mid-word and push the whole row onto three lines.
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
|
||||
'<td>'+roleCell+'</td>'+
|
||||
'<td>'+projRoleCell+'</td>'+
|
||||
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
|
||||
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></td>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<td><div class="cellactions">'+
|
||||
'<button class="mini" onclick="resetPw(\''+escUid+'\',\''+escUname+'\')">Reset password</button>'+
|
||||
disableBtn+delBtn+
|
||||
'</div></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
// Nine columns outrun even the widened card, so the table scrolls inside .tscroll
|
||||
// rather than forcing every cell to wrap. The explanatory note stays outside it.
|
||||
wrap.innerHTML='<div class="tscroll"><table class="users grid"><thead><tr>'+
|
||||
'<th>Username</th><th>Name</th><th>Email</th>'+
|
||||
'<th title="What this account may do in the app">Permissions</th>'+
|
||||
'<th title="Job function on the project — descriptive only">Project role</th>'+
|
||||
'<th title="Which projects this user can access, and their role on each">Project access</th>'+
|
||||
'<th>Status</th><th>Last login</th><th>Actions</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||
'<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||||
'<em>Administrator</em>: manages users, settings and every project. '+
|
||||
'<em>Project Admin</em>: on their assigned projects, may delete work packages, '+
|
||||
'change a completed SOP, and delete the project. '+
|
||||
'<em>Project User</em>: creates and edits work packages and authors the SOP, '+
|
||||
'but cannot delete WPs or change the SOP once it\'s complete. '+
|
||||
'<strong>Project role</strong> is the person\'s job function — it feeds the SOP '+
|
||||
'team pickers and notification routing, and grants nothing on its own.</div>';
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const msg=document.getElementById('users-create-msg');
|
||||
const username=document.getElementById('nu-username').value.trim();
|
||||
const full_name=document.getElementById('nu-fullname').value.trim();
|
||||
const email=document.getElementById('nu-email').value.trim();
|
||||
const role=document.getElementById('nu-role').value;
|
||||
const project_role=(document.getElementById('nu-project-role')||{}).value||'';
|
||||
const password=document.getElementById('nu-password').value;
|
||||
if(!username){ msg.style.color='var(--red)'; msg.textContent='Username is required.'; return; }
|
||||
if(password.length<12){ msg.style.color='var(--red)'; msg.textContent='Password must be at least 12 characters.'; return; }
|
||||
msg.style.color='var(--muted)'; msg.textContent='Creating…';
|
||||
const { status, json } = await api('POST','/api/auth/users',{username,full_name,email,role,project_role,password});
|
||||
if(status===200){
|
||||
msg.style.color='var(--green)'; msg.textContent='✅ Created '+username+'.';
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id=>document.getElementById(id).value='');
|
||||
loadUsers();
|
||||
} else {
|
||||
msg.style.color='var(--red)';
|
||||
msg.textContent='❌ '+((json && json.detail) ? json.detail : ('Failed (HTTP '+status+').'));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetPw(id, username){
|
||||
const pw=prompt('New password for "'+username+'" (min 8 characters):');
|
||||
if(pw===null) return;
|
||||
if(pw.length<8){ alert('Password must be at least 8 characters.'); return; }
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status===200) alert('Password reset for '+username+'.');
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// Change a user's role (user ↔ admin) at any time. The server enforces the same
|
||||
// admin-only rule as every other user-management call, and refuses to remove the
|
||||
// last admin. On any failure we reload so the dropdown snaps back to the truth.
|
||||
async function changeRole(id, role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||||
if(status===200){ loadUsers(); }
|
||||
else {
|
||||
alert('Could not change permissions for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
async function changeProjectRole(id, project_role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
|
||||
if(status===200){ loadUsers(); }
|
||||
else {
|
||||
alert('Could not set the project role for '+username+': '+((json && json.detail)||('HTTP '+status)));
|
||||
loadUsers();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteUser(id, username){
|
||||
if(!confirm('Delete user "'+username+'"? This cannot be undone.')) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status===200) loadUsers();
|
||||
else alert('Failed: '+((json && json.detail)||('HTTP '+status)));
|
||||
}
|
||||
|
||||
// ── project access assignment ───────────────────────────────────────────────────
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
if(status!==200 || !json){ alert('Could not load projects (HTTP '+status+').'); return; }
|
||||
openProjectModal(id, username, json.projects||[], new Set(json.assigned||[]), json.user, json.roles||{});
|
||||
}
|
||||
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
function openProjectModal(userId, username, projects, assigned, userObj, roles){
|
||||
closeProjectModal();
|
||||
const isAdmin = userObj && normRole(userObj.role)==='admin';
|
||||
const acctRole = userObj ? normRole(userObj.role) : 'project_user';
|
||||
roles = roles || {};
|
||||
// Each project row: access tick + the role ON THAT project. "Same as account"
|
||||
// inherits the account's Permissions, so the common case needs no thought.
|
||||
// Live jobs first — an archived one is still listed (an existing assignment has to
|
||||
// stay removable) but it is finished work, so it doesn't belong at the top of a
|
||||
// list you're using to staff someone.
|
||||
projects = projects.slice().sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
|
||||
const items = projects.length ? projects.map(p => {
|
||||
const on = assigned.has(p.id);
|
||||
const cur = roles[p.id] || '';
|
||||
const sel = '<select data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+
|
||||
' style="padding:3px 6px;font-size:12px;border:1px solid var(--border-strong);background:#fff;">'+
|
||||
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[acctRole]||acctRole)+')</option>'+
|
||||
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||
'</select>';
|
||||
return '<div style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid var(--border);font-size:13px;">'+
|
||||
'<label style="display:flex;align-items:center;gap:8px;flex:1;min-width:0;cursor:pointer;">'+
|
||||
'<input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
|
||||
' onchange="projRowToggled(this)">'+
|
||||
'<span style="overflow:hidden;text-overflow:ellipsis;"><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
|
||||
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+'</span>'+
|
||||
'</label>'+ sel +
|
||||
'</div>';
|
||||
}).join('') : '<div class="note">No projects exist yet.</div>';
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'proj-modal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(20,30,50,.5);display:flex;align-items:center;justify-content:center;z-index:10002;padding:20px;';
|
||||
modal.innerHTML =
|
||||
'<div style="background:#fff;border-radius:10px;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);">'+
|
||||
'<div style="padding:14px 18px;border-bottom:1px solid var(--border);font-weight:700;">Project access & permissions — '+uesc(username)+'</div>'+
|
||||
'<div style="padding:14px 18px;overflow:auto;">'+
|
||||
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can access every project regardless of assignment.</div>'
|
||||
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role on each. '+
|
||||
'<strong>Project Admin</strong> can delete work packages, change a completed SOP and delete that project; '+
|
||||
'<strong>Project User</strong> cannot. Leave it on <em>Same as account</em> to use their Permissions setting.</div>')+
|
||||
'<div id="proj-list">'+items+'</div>'+
|
||||
'</div>'+
|
||||
'<div style="padding:12px 18px;border-top:1px solid var(--border);display:flex;gap:8px;justify-content:flex-end;">'+
|
||||
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||||
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
modal.addEventListener('click', e => { if(e.target===modal) closeProjectModal(); });
|
||||
document.body.appendChild(modal);
|
||||
const saveBtn = document.getElementById('proj-save');
|
||||
if(saveBtn) saveBtn.onclick = async () => {
|
||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c=>c.value);
|
||||
const roleMap = {};
|
||||
ids.forEach(pid => {
|
||||
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
|
||||
if(sel && sel.value) roleMap[pid] = sel.value;
|
||||
});
|
||||
const { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids, roles:roleMap});
|
||||
if(status===200){ closeProjectModal(); loadUsers(); }
|
||||
else alert('Save failed (HTTP '+status+').');
|
||||
};
|
||||
}
|
||||
|
||||
// ── projects: archive / unarchive ───────────────────────────────────────────────
|
||||
// Archiving is the answer to "this job is over but I can't throw the data away".
|
||||
// An archived project disappears from every picker, switcher and search in the
|
||||
@@ -615,8 +298,8 @@ async function loadDefaultMembers(){
|
||||
renderDefaultMembers();
|
||||
}
|
||||
|
||||
// Same idea as projRowToggled() — the role only matters while the tick is on — but
|
||||
// here the select sits in a sibling <td>, so the lookup is scoped to the row.
|
||||
// The role only matters while the tick is on. The select sits in a sibling <td>, so
|
||||
// the lookup is scoped to the row.
|
||||
function defMemToggled(cb){
|
||||
const row = cb.closest('tr');
|
||||
const sel = row && row.querySelector('select');
|
||||
@@ -634,8 +317,7 @@ function renderDefaultMembers(){
|
||||
const who = '<td><strong>'+uesc(u.username)+'</strong>'+
|
||||
(u.full_name ? ' <span class="note">'+uesc(u.full_name)+'</span>' : '')+'</td>'+
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'—')+'</span></td>';
|
||||
// Admins reach every project already, so there is nothing to add them to —
|
||||
// the same thing projAccessCell() says in the user table.
|
||||
// Admins reach every project already, so there is nothing to add them to.
|
||||
if(role === 'admin'){
|
||||
return '<tr>'+who+
|
||||
'<td><span class="tag admin">'+uesc(PERM_LABELS.admin)+'</span></td>'+
|
||||
@@ -645,8 +327,15 @@ function renderDefaultMembers(){
|
||||
}
|
||||
const on = !!u.auto_add_projects;
|
||||
const cur = u.auto_add_role || '';
|
||||
// Every project-scoped role is offered, super user included: this card is
|
||||
// admin-only, and "the QA lead runs the users on every new job" is exactly the
|
||||
// sort of standing rule it exists to express.
|
||||
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
|
||||
uesc(PERM_LABELS[role]||role)+')</option>']
|
||||
.concat(PROJECT_SCOPED_ROLES.map(r =>
|
||||
'<option value="'+r+'"'+(cur===r?' selected':'')+'>'+uesc(PERM_LABELS[r])+' here</option>'));
|
||||
return '<tr>'+who+
|
||||
'<td><span class="tag user">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||
'<td><span class="tag '+roleTagClass(role)+'">'+uesc(PERM_LABELS[role]||role)+'</span></td>'+
|
||||
'<td><label class="chk">'+
|
||||
'<input type="checkbox" id="defmem-cb-'+uesc(u.id)+'"'+(on?' checked':'')+
|
||||
' title="Add this user to every project created from now on"'+
|
||||
@@ -654,11 +343,7 @@ function renderDefaultMembers(){
|
||||
'</label></td>'+
|
||||
'<td><select class="role-select" id="defmem-role-'+uesc(u.id)+'"'+(on?'':' disabled')+
|
||||
' title="The role this user gets on those projects"'+
|
||||
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+
|
||||
'<option value=""'+(cur===''?' selected':'')+'>Same as account ('+uesc(PERM_LABELS[role]||role)+')</option>'+
|
||||
'<option value="project_admin"'+(cur==='project_admin'?' selected':'')+'>Project Admin here</option>'+
|
||||
'<option value="project_user"'+(cur==='project_user'?' selected':'')+'>Project User here</option>'+
|
||||
'</select></td>'+
|
||||
' onchange="setAutoAdd(\''+uid+'\',\''+uname+'\')">'+opts.join('')+'</select></td>'+
|
||||
'</tr>';
|
||||
}).join('');
|
||||
wrap.innerHTML =
|
||||
@@ -669,8 +354,8 @@ function renderDefaultMembers(){
|
||||
'<th title="Their role on those projects">Role on those projects</th>'+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+
|
||||
'<div class="note">This only affects projects created <strong>from now on</strong> — existing projects '+
|
||||
'are untouched. Use <strong>Project access</strong> in the user table above to add someone to a project '+
|
||||
'that already exists.</div>';
|
||||
'are untouched. Use <strong>Project access</strong> on the <a class="home" href="users.html">User '+
|
||||
'Directory</a> to add someone to a project that already exists.</div>';
|
||||
}
|
||||
|
||||
// Saves on every tick and every dropdown change — there is no Save button, so a
|
||||
|
||||
@@ -124,14 +124,24 @@
|
||||
return r === 'user' ? 'project_user' : r;
|
||||
};
|
||||
window.wpIsAdmin = function () { return window.wpRole() === 'admin'; };
|
||||
// A Project Super User is a Project Admin with user administration on top, so it
|
||||
// counts here too (server: auth.is_project_admin).
|
||||
window.wpIsProjectAdmin = function () {
|
||||
var r = window.wpRole();
|
||||
return r === 'admin' || r === 'project_admin';
|
||||
return r === 'admin' || r === 'project_super_user' || r === 'project_admin';
|
||||
};
|
||||
// Deleting a work package, deleting a project, and editing a completed SOP are
|
||||
// all Project Admin actions (see server require_project_admin).
|
||||
window.wpCanDeleteWP = window.wpIsProjectAdmin;
|
||||
window.wpCanEditCompletedSOP = window.wpIsProjectAdmin;
|
||||
// Whether this account can administer USER accounts. The account role is only half
|
||||
// the answer — the role can also be held on a single project — so anything that
|
||||
// needs the real verdict asks GET /api/auth/user-scope (users.js does). This is the
|
||||
// cheap hint used to decide whether to bother offering a control.
|
||||
window.wpMayManageUsers = function () {
|
||||
var r = window.wpRole();
|
||||
return r === 'admin' || r === 'project_super_user';
|
||||
};
|
||||
|
||||
// ── app feature flags ──────────────────────────────────────────────────────
|
||||
// Cached per page load. Pages that must know before rendering should await
|
||||
@@ -185,6 +195,11 @@
|
||||
wrap.appendChild(who);
|
||||
var onAdmin = /(^|\/)admin\.html$/.test(location.pathname);
|
||||
if (window.wpIsAdmin() && !onAdmin) { wrap.appendChild(sep()); wrap.appendChild(link('Admin', null, 'admin.html')); }
|
||||
// The directory is readable by everyone — it's how you find who is on your job —
|
||||
// so it is offered to everyone, not just the people who can edit accounts.
|
||||
if (!/(^|\/)users\.html$/.test(location.pathname)) {
|
||||
wrap.appendChild(sep()); wrap.appendChild(link('Users', null, 'users.html'));
|
||||
}
|
||||
// Always offered; wp-format.js may still be parsing when the menu is built, so
|
||||
// the check happens at click time rather than once, up front.
|
||||
wrap.appendChild(sep());
|
||||
|
||||
89
html/console-util.js
Normal file
89
html/console-util.js
Normal file
@@ -0,0 +1,89 @@
|
||||
/* Shared helpers for the suite's admin pages (Admin Console, User Directory).
|
||||
|
||||
These used to live in admin.js. They are here because the User Directory needs
|
||||
the same escaping and the same role vocabulary, and a second copy of either is a
|
||||
liability: a divergent jsq() is an XSS, and a divergent role list quietly offers
|
||||
a permission the server will refuse.
|
||||
|
||||
Loaded as plain globals (no modules) to match the rest of the suite. */
|
||||
|
||||
// ── api ──────────────────────────────────────────────────────────────────────
|
||||
// Never throws: returns {status, json} with status 0 when the request itself
|
||||
// failed, so every caller can branch on one shape.
|
||||
async function api(method, path, body){
|
||||
const opt = { method, headers:{ 'Accept':'application/json' } };
|
||||
if(body !== undefined){ opt.headers['Content-Type']='application/json'; opt.body=JSON.stringify(body); }
|
||||
try {
|
||||
const r = await fetch(path, opt);
|
||||
const t = await r.text();
|
||||
let json; try { json = t ? JSON.parse(t) : null; } catch(_){ json = t; }
|
||||
return { status:r.status, json };
|
||||
} catch(e){ return { status:0, json:String(e) }; }
|
||||
}
|
||||
|
||||
// The message to show for a failed call, preferring the server's own words.
|
||||
function apiError(status, json, fallback){
|
||||
if(json && json.detail) return json.detail;
|
||||
if(status === 0) return 'Could not reach the server.';
|
||||
if(status === 401) return 'Not signed in. Reload and log in again.';
|
||||
return (fallback || 'Request failed') + ' (HTTP ' + status + ').';
|
||||
}
|
||||
|
||||
// ── escaping ─────────────────────────────────────────────────────────────────
|
||||
function uesc(v){ return v==null ? '' : String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||||
|
||||
// A value bound into an inline handler — onclick="fn('…')" — is escaped TWICE: once
|
||||
// for the JS string literal it lands in, and again for the HTML attribute carrying
|
||||
// it. The order is the whole point. Escape the backslashes FIRST, then the quotes,
|
||||
// then hand the result to uesc: uesc leaves \ and ' alone, so the JS escaping
|
||||
// survives, and the browser decodes the entities before the JS parser runs.
|
||||
//
|
||||
// Doing it the other way round — uesc(v).replace(/'/g,"\\'") — silently fails on a
|
||||
// value containing a backslash: the \ we add is itself escaped by the stored one,
|
||||
// the quote closes the literal, and everything after it runs as code. Project names,
|
||||
// full names and usernames are free text that a signed-in user can write, so that is
|
||||
// a real path from a project_user to whatever an admin's session can do. Use jsq()
|
||||
// for EVERY value that lands inside an inline handler.
|
||||
function jsq(v){
|
||||
return uesc(String(v==null ? '' : v).replace(/\\/g,'\\\\').replace(/'/g,"\\'"));
|
||||
}
|
||||
|
||||
// ── role vocabulary (mirrors server/auth.py) ─────────────────────────────────
|
||||
// Permissions roles: what an account may DO. Ordered most- to least-privileged,
|
||||
// same as auth.ROLES, because that is the order the dropdowns render in.
|
||||
const PERM_ROLES = ['admin','project_super_user','project_admin','project_user'];
|
||||
const PERM_LABELS = {
|
||||
admin:'Administrator',
|
||||
project_super_user:'Project Super User',
|
||||
project_admin:'Project Admin',
|
||||
project_user:'Project User',
|
||||
};
|
||||
// One-line description of each, used in the legends and dropdown titles.
|
||||
const PERM_HELP = {
|
||||
admin:'Manages users, app settings and every project.',
|
||||
project_super_user:'On their assigned projects: everything a Project Admin can do, '+
|
||||
'plus creating and managing that project\'s user accounts.',
|
||||
project_admin:'On their assigned projects: may delete work packages, change a completed SOP, '+
|
||||
'and delete the project.',
|
||||
project_user:'Creates and edits work packages and authors the SOP, but cannot delete WPs '+
|
||||
'or change the SOP once it is complete.',
|
||||
};
|
||||
// Roles that can be held on a SINGLE project (ProjectMember.role); '' inherits the
|
||||
// account's own. 'admin' is app-wide by definition and never appears here.
|
||||
const PROJECT_SCOPED_ROLES = ['project_super_user','project_admin','project_user'];
|
||||
// Job functions on a project. Descriptive only — no permissions attached.
|
||||
const PROJECT_ROLES = ['Project Manager','Assistant Project Manager','Construction Manager',
|
||||
'Quality Manager','Superintendent','General Foreman','Foreman','Planner / Scheduler',
|
||||
'BIM / VDC Coordinator','Engineer','Safety (HSE)','Warehouse / Materials','Commissioning',
|
||||
'Field Technician'];
|
||||
|
||||
// Accounts created before permissions roles existed carry the legacy value 'user'.
|
||||
function normRole(r){ return r==='user' ? 'project_user' : (PERM_ROLES.indexOf(r)>=0 ? r : 'project_user'); }
|
||||
function roleLabel(r){ const n = normRole(r); return PERM_LABELS[n] || n; }
|
||||
// Which pill a role wears. Admin and super user each get their own colour because
|
||||
// "can reach every project" and "can create users here" are the two facts you scan
|
||||
// this column for.
|
||||
function roleTagClass(r){
|
||||
const n = normRole(r);
|
||||
return n==='admin' ? 'admin' : n==='project_super_user' ? 'super' : 'user';
|
||||
}
|
||||
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%; }
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body { -webkit-text-size-adjust: 100%; }
|
||||
@@ -87,5 +88,6 @@
|
||||
<script src="help.js"></script>
|
||||
<script src="field.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -373,6 +373,13 @@
|
||||
<button class="card-button" id="card-field-btn">Open Field View</button>
|
||||
</a>
|
||||
|
||||
<!-- USER DIRECTORY -->
|
||||
<a href="users.html" class="card" id="card-users">
|
||||
<h3>User Directory</h3>
|
||||
<p>Who is on this project — names, job functions and how to reach them. Administrators and Project Super Users also create accounts, set permissions and grant project access from here.</p>
|
||||
<button class="card-button" id="card-users-btn">Open Directory</button>
|
||||
</a>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- COMMENTS SECTION -->
|
||||
|
||||
@@ -14,15 +14,16 @@
|
||||
'use strict';
|
||||
// Bumped when the shell file list changes, so clients fetch the new assets
|
||||
// instead of serving a half-old shell from the previous cache.
|
||||
const CACHE = 'wp-suite-shell-v5';
|
||||
const CACHE = 'wp-suite-shell-v6';
|
||||
const SHELL = [
|
||||
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
|
||||
'/field.html', '/login.html', '/admin.html',
|
||||
'/field.html', '/login.html', '/admin.html', '/users.html',
|
||||
'/theme-light.css', '/work-package-suite-styles.css', '/wp-creation-styles.css',
|
||||
'/wp-chrome.css',
|
||||
'/wp-chrome.css', '/console.css', '/wp-sidenav.css',
|
||||
'/auth-guard.js', '/project-data.js', '/feedback-config.js', '/help.js',
|
||||
'/work-package-suite-app.js', '/wp-creation-app.js', '/field.js',
|
||||
'/wp-chrome.js', '/wp-format.js', '/login.js', '/admin.js',
|
||||
'/wp-chrome.js', '/wp-sidenav.js', '/wp-format.js', '/login.js',
|
||||
'/console-util.js', '/admin.js', '/users.js',
|
||||
'/prime-controls-logo.jpg', '/favicon.ico',
|
||||
'/manifest.webmanifest', '/icon-192.png', '/icon-512.png',
|
||||
];
|
||||
|
||||
106
html/users.html
Normal file
106
html/users.html
Normal file
@@ -0,0 +1,106 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>User Directory — Work Package Suite</title>
|
||||
<script src="auth-guard.js"></script>
|
||||
<!-- Date/number formatting. Must parse BEFORE the app scripts: they format
|
||||
timestamps during their own boot. -->
|
||||
<script src="wp-format.js"></script>
|
||||
<link rel="icon" href="favicon.ico" sizes="any">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#161616">
|
||||
<link rel="stylesheet" href="theme-light.css">
|
||||
<link rel="stylesheet" href="wp-chrome.css">
|
||||
<link rel="stylesheet" href="console.css">
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<style>
|
||||
/* Page-specific only — everything structural is in console.css.
|
||||
|
||||
The directory is one wide table, so the column exceptions live here: email is
|
||||
the one cell long enough to stretch a row, and the two role dropdowns need
|
||||
room for "Assistant Project Manager" without pushing Actions off screen. */
|
||||
#users-table table td:nth-child(3){ max-width:230px; overflow:hidden; text-overflow:ellipsis; }
|
||||
#users-banner:not(:empty), #scope-banner:not(:empty){ margin-bottom:var(--s3); }
|
||||
/* The create form is a lot of fields; give the password one room to breathe and
|
||||
let the project picker take a full row of its own. */
|
||||
#nu-password{ flex:1 1 200px; }
|
||||
#nu-projects{ margin-top:var(--s2); }
|
||||
#nu-projects .pickrow{ padding:var(--s1) var(--s1); }
|
||||
/* A manager with one project doesn't need a scrolling picker; a manager with
|
||||
thirty does, and it must not push the Create button below the fold. */
|
||||
#nu-project-list{ max-height:200px; overflow:auto; border:1px solid var(--border); }
|
||||
.whoami-chip{ font-size:12px; color:var(--muted); }
|
||||
.whoami-chip strong{ color:var(--text); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- SHARED DARK APP BAR -->
|
||||
<header class="wp-appbar">
|
||||
<a href="index.html" class="wp-appbar-brand" title="Back to site">
|
||||
<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>
|
||||
<span class="wp-appbar-title">Work Package Suite <span class="wp-appbar-sub">| User Directory</span></span>
|
||||
</a>
|
||||
</header>
|
||||
|
||||
<div class="wrap" id="users-main" style="display:none">
|
||||
<div class="row" style="justify-content:space-between; margin-bottom:var(--s5)">
|
||||
<div>
|
||||
<h1>User Directory</h1>
|
||||
<div class="sub" style="margin:0" id="dir-sub">The people on your projects — who they are, and how to reach them.</div>
|
||||
</div>
|
||||
<div class="row"><a class="home" href="index.html">← Site</a></div>
|
||||
</div>
|
||||
|
||||
<!-- WHAT YOU MAY DO HERE (rendered from GET /api/auth/user-scope) -->
|
||||
<div id="scope-banner"></div>
|
||||
|
||||
<!-- THE DIRECTORY -->
|
||||
<div class="card">
|
||||
<h2>People</h2>
|
||||
<div class="sub" id="people-sub"></div>
|
||||
<div class="toolbar">
|
||||
<button onclick="loadUsers()">Refresh</button>
|
||||
<input id="user-search" placeholder="Search name / username / email / job function…" oninput="renderUsers()">
|
||||
<select id="user-filter" onchange="renderUsers()">
|
||||
<option value="">Everyone</option>
|
||||
<option value="active">Active only</option>
|
||||
<option value="disabled">Disabled only</option>
|
||||
<option value="mine">Accounts I manage</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="users-banner"></div>
|
||||
<div id="users-table"><div class="note">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- ADD A USER (managers only; hidden otherwise) -->
|
||||
<div class="card" id="create-card" style="display:none">
|
||||
<h2>Add a user</h2>
|
||||
<div class="sub" id="create-sub"></div>
|
||||
<div class="urow">
|
||||
<input id="nu-username" placeholder="Username *" autocomplete="off">
|
||||
<input id="nu-fullname" placeholder="Full name" autocomplete="off">
|
||||
<input id="nu-email" placeholder="Email" autocomplete="off">
|
||||
<select id="nu-role" title="Permissions — what this account may do"></select>
|
||||
<select id="nu-project-role" title="Job function on the project"></select>
|
||||
<input id="nu-password" type="password" placeholder="Password (min 12)" autocomplete="new-password">
|
||||
</div>
|
||||
<div id="nu-projects">
|
||||
<div class="note" id="nu-projects-label" style="margin-bottom:var(--s1)"></div>
|
||||
<div id="nu-project-list"></div>
|
||||
</div>
|
||||
<div class="row" style="margin-top:var(--s3)">
|
||||
<button class="primary" onclick="createUser()">Create user</button>
|
||||
<span id="users-create-msg" class="note" style="margin:0"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="console-util.js"></script>
|
||||
<script src="users.js"></script>
|
||||
<script src="wp-chrome.js"></script>
|
||||
<script src="wp-sidenav.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
473
html/users.js
Normal file
473
html/users.js
Normal file
@@ -0,0 +1,473 @@
|
||||
/* User Directory for the Work Package Suite.
|
||||
|
||||
Moved out of the Admin Console because user administration is no longer
|
||||
admin-only: a PROJECT SUPER USER creates and manages the accounts on the projects
|
||||
they administer, which means the page has to be reachable by people who must never
|
||||
see the console's settings, diagnostics or app-wide switches.
|
||||
|
||||
ACCESS — three audiences on one page, decided by GET /api/auth/user-scope:
|
||||
• App admin every account, every control.
|
||||
• Project super user the accounts on the projects they administer. Controls
|
||||
appear per row: an account that is also on a job they
|
||||
don't administer is read-only, and the row says why.
|
||||
• Everyone else a read-only directory of the people on their own
|
||||
projects. No controls at all.
|
||||
|
||||
The server enforces every one of those rules (server/app.py: require_user_manager,
|
||||
require_manage_user, visible_user_ids). Nothing here is a security boundary — it is
|
||||
here so nobody is shown a button that would only 403, and so the reason is on the
|
||||
page instead of in an alert.
|
||||
|
||||
Shared helpers (api, uesc, jsq, the role vocabulary) come from console-util.js. */
|
||||
|
||||
let _users = []; // the directory as the server scoped it
|
||||
let _scope = null; // GET /api/auth/user-scope
|
||||
let _meId = null;
|
||||
|
||||
// ── boot ──────────────────────────────────────────────────────────────────────
|
||||
async function boot(){
|
||||
document.getElementById('users-main').style.display = '';
|
||||
_meId = (window.WP_USER && window.WP_USER.id) || null;
|
||||
const { status, json } = await api('GET','/api/auth/user-scope');
|
||||
// A failed scope call must not leave the page pretending to be read-only-with-no-
|
||||
// reason: fall back to the least-privileged rendering and say so.
|
||||
_scope = (status === 200 && json) ? json : { can_manage_users:false, scope:'projects',
|
||||
grantable_roles:[], grantable_project_roles:[], managed_projects:[], project_roles:PROJECT_ROLES };
|
||||
if(status !== 200){
|
||||
banner('scope-banner','bad','❌ '+apiError(status, json, 'Could not work out what you may do here')+
|
||||
' Showing the directory read-only.');
|
||||
} else {
|
||||
renderScope();
|
||||
}
|
||||
renderCreateForm();
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
function banner(id, kind, text){
|
||||
const el = document.getElementById(id);
|
||||
if(!el) return;
|
||||
if(!text){ el.innerHTML=''; return; }
|
||||
el.className = 'banner' + (kind ? ' '+kind : '');
|
||||
el.textContent = text;
|
||||
}
|
||||
|
||||
// What this account may do here, stated once at the top rather than implied by which
|
||||
// buttons happen to be missing.
|
||||
function renderScope(){
|
||||
const el = document.getElementById('scope-banner');
|
||||
const sub = document.getElementById('dir-sub');
|
||||
if(!_scope.can_manage_users){
|
||||
el.innerHTML = '';
|
||||
if(sub) sub.textContent = 'The people on your projects — who they are, and how to reach them. '+
|
||||
'Only an administrator or a Project Super User can change accounts.';
|
||||
return;
|
||||
}
|
||||
if(_scope.scope === 'all'){
|
||||
el.className = 'banner';
|
||||
el.innerHTML = 'You are an <strong>Administrator</strong>: you manage every account in the suite. '+
|
||||
'App settings, diagnostics and the default-member rules live in the '+
|
||||
'<a class="home" href="admin.html">Admin Console</a>.';
|
||||
if(sub) sub.textContent = 'Every login account in the suite.';
|
||||
return;
|
||||
}
|
||||
const names = (_scope.managed_projects||[]).map(p => p.name || p.number || p.id);
|
||||
el.className = 'banner';
|
||||
el.innerHTML = 'You are a <strong>Project Super User</strong> on '+
|
||||
(names.length === 1 ? uesc(names[0]) : names.length+' projects')+
|
||||
' — you create and manage the accounts on '+(names.length === 1 ? 'that project' : 'those projects')+
|
||||
(names.length > 1 ? ': <strong>'+names.map(uesc).join('</strong>, <strong>')+'</strong>' : '')+'. '+
|
||||
'An account that is also on a project you don’t administer is read-only here.';
|
||||
if(sub) sub.textContent = 'The people on your projects, and the accounts you administer.';
|
||||
}
|
||||
|
||||
// ── the table ─────────────────────────────────────────────────────────────────
|
||||
async function loadUsers(){
|
||||
const wrap = document.getElementById('users-table');
|
||||
const { status, json } = await api('GET','/api/auth/users');
|
||||
if(status !== 200 || !Array.isArray(json)){
|
||||
banner('users-banner','bad','❌ '+apiError(status, json, 'Could not load the directory'));
|
||||
wrap.innerHTML = ''; return;
|
||||
}
|
||||
banner('users-banner','', '');
|
||||
_users = json;
|
||||
renderUsers();
|
||||
}
|
||||
|
||||
function manages(){ return !!(_scope && _scope.can_manage_users); }
|
||||
|
||||
function renderUsers(){
|
||||
const wrap = document.getElementById('users-table');
|
||||
const q = ((document.getElementById('user-search')||{}).value||'').trim().toLowerCase();
|
||||
const f = ((document.getElementById('user-filter')||{}).value||'');
|
||||
const total = _users.length;
|
||||
const sub = document.getElementById('people-sub');
|
||||
if(sub){
|
||||
sub.textContent = manages()
|
||||
? 'Login accounts you can see. The ones you administer carry controls; the rest are listed for reference.'
|
||||
: 'Everyone on the projects you can access, plus the administrators.';
|
||||
}
|
||||
if(!total){ wrap.innerHTML = '<div class="note">Nobody to show yet.</div>'; return; }
|
||||
const list = _users.filter(u => {
|
||||
if(f === 'active' && !u.is_active) return false;
|
||||
if(f === 'disabled' && u.is_active) return false;
|
||||
if(f === 'mine' && !u.manageable) return false;
|
||||
if(!q) return true;
|
||||
return ((u.username||'')+' '+(u.full_name||'')+' '+(u.email||'')+' '+
|
||||
(u.project_role||'')+' '+roleLabel(u.role)).toLowerCase().indexOf(q) >= 0;
|
||||
});
|
||||
const count = '<div class="note">'+list.length+' of '+total+' '+(total===1?'person':'people')+'</div>';
|
||||
if(!list.length){ wrap.innerHTML = count+'<div class="note">Nothing matches.</div>'; return; }
|
||||
|
||||
const head = manages()
|
||||
? ['Username','Name','Email',
|
||||
['Permissions','What this account may do in the app'],
|
||||
['Project role','Job function on the project — descriptive only'],
|
||||
['Project access','Which projects this user can access, and their role on each'],
|
||||
'Status','Last login','Actions']
|
||||
: ['Name','Username','Email',
|
||||
['Permissions','What this account may do in the app'],
|
||||
['Project role','Job function on the project — descriptive only'],
|
||||
'Status'];
|
||||
const ths = head.map(h => Array.isArray(h)
|
||||
? '<th title="'+uesc(h[1])+'">'+uesc(h[0])+'</th>' : '<th>'+uesc(h)+'</th>').join('');
|
||||
const rows = list.map(manages() ? managerRow : readonlyRow).join('');
|
||||
wrap.innerHTML = count+'<div class="tscroll"><table class="grid"><thead><tr>'+ths+
|
||||
'</tr></thead><tbody>'+rows+'</tbody></table></div>'+legend();
|
||||
}
|
||||
|
||||
function legend(){
|
||||
if(!manages()){
|
||||
return '<div class="note" style="margin-top:10px"><strong>Project role</strong> is the person’s job '+
|
||||
'function — it feeds the SOP team pickers and notification routing, and grants nothing on its own.</div>';
|
||||
}
|
||||
return '<div class="note" style="margin-top:10px"><strong>Permissions</strong> — '+
|
||||
PERM_ROLES.map(r => '<em>'+uesc(PERM_LABELS[r])+'</em>: '+uesc(PERM_HELP[r])).join(' ')+
|
||||
' <strong>Project role</strong> is the person’s job function — it feeds the SOP team pickers '+
|
||||
'and notification routing, and grants nothing on its own.</div>';
|
||||
}
|
||||
|
||||
// The read-only card: name, contact, role. No ids are bound into handlers because
|
||||
// there are no handlers — that is the point of this rendering.
|
||||
function readonlyRow(u){
|
||||
return '<tr>'+
|
||||
'<td><strong>'+uesc(u.full_name || u.username)+'</strong>'+(u.id===_meId?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.username)+'</td>'+
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+
|
||||
(u.email ? '<a class="home" href="mailto:'+uesc(u.email)+'">'+uesc(u.email)+'</a>' : '—')+'</span></td>'+
|
||||
'<td><span class="tag '+roleTagClass(u.role)+'">'+uesc(roleLabel(u.role))+'</span></td>'+
|
||||
'<td>'+uesc(u.project_role || '—')+'</td>'+
|
||||
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
function managerRow(u){
|
||||
const me = u.id === _meId;
|
||||
const uid = jsq(u.id), uname = jsq(u.username);
|
||||
const can = !!u.manageable;
|
||||
const why = u.manage_blocked_reason || '';
|
||||
const fmt = s => s ? wpFormatDateTime(s) : '—';
|
||||
const role = normRole(u.role);
|
||||
|
||||
// Your own row never offers the controls that could lock you out of the app.
|
||||
const roleCell = me
|
||||
? '<span class="tag '+roleTagClass(role)+'">'+uesc(roleLabel(role))+'</span><span class="me-tag">locked</span>'
|
||||
: !can
|
||||
? '<span class="tag '+roleTagClass(role)+'" title="'+uesc(why)+'">'+uesc(roleLabel(role))+'</span>'
|
||||
: roleSelect(uid, uname, role);
|
||||
|
||||
// Job function follows the same permission as everything else on the row. Note a
|
||||
// super user cannot edit their OWN row: the server refuses account changes to any
|
||||
// admin or super-user account, including the caller's.
|
||||
const projRoleCell = can
|
||||
? projRoleSelect(uid, uname, u.project_role || '')
|
||||
: projRoleReadonly(u, can, why);
|
||||
|
||||
const actions = [];
|
||||
if(can && !me) actions.push('<button class="mini" onclick="resetPw(\''+uid+'\',\''+uname+'\')">Reset password</button>');
|
||||
if(can && !me) actions.push('<button class="mini" onclick="toggleActive(\''+uid+'\','+(!u.is_active)+')">'+
|
||||
(u.is_active?'Disable':'Enable')+'</button>');
|
||||
if(can && !me) actions.push('<button class="mini danger" onclick="deleteUser(\''+uid+'\',\''+uname+'\')">Delete</button>');
|
||||
if(me) actions.push('<button class="mini" disabled title="Use the Password link in the top bar to change your own">—</button>');
|
||||
if(!can && !me) actions.push('<span class="note" style="margin:0" title="'+uesc(why)+'">read-only</span>');
|
||||
|
||||
return '<tr'+(can||me ? '' : ' class="is-locked"')+'>'+
|
||||
'<td><strong>'+uesc(u.username)+'</strong>'+(me?'<span class="me-tag">you</span>':'')+'</td>'+
|
||||
'<td>'+uesc(u.full_name||'')+'</td>'+
|
||||
// The address is truncated with the full value on the title: a long one used to
|
||||
// wrap mid-word and push the whole row onto three lines.
|
||||
'<td class="ell" title="'+uesc(u.email||'')+'"><span>'+uesc(u.email||'')+'</span></td>'+
|
||||
'<td>'+roleCell+'</td>'+
|
||||
'<td>'+projRoleCell+'</td>'+
|
||||
'<td><div class="cellactions">'+projAccessCell(u)+'</div></td>'+
|
||||
'<td><span class="tag '+(u.is_active?'on':'off')+'">'+(u.is_active?'active':'disabled')+'</span></td>'+
|
||||
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<td><div class="cellactions">'+actions.join('')+'</div></td>'+
|
||||
'</tr>';
|
||||
}
|
||||
|
||||
// Only the roles the server said this caller may grant are offered. The account's
|
||||
// CURRENT role is always included even when it isn't grantable, or the dropdown would
|
||||
// silently misreport a Project Admin as a Project User the moment it renders.
|
||||
function roleSelect(uid, uname, role){
|
||||
const grantable = (_scope && _scope.grantable_roles) || [];
|
||||
const opts = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === role);
|
||||
return '<select class="role-select'+(role==='admin'?' is-admin':'')+
|
||||
'" title="Change what this account may do" onchange="changeRole(\''+uid+'\',this.value,\''+uname+'\')">'+
|
||||
opts.map(r => '<option value="'+r+'"'+(role===r?' selected':'')+
|
||||
(grantable.indexOf(r) < 0 ? ' disabled' : '')+'>'+uesc(PERM_LABELS[r])+'</option>').join('')+
|
||||
'</select>';
|
||||
}
|
||||
|
||||
function projRoleSelect(uid, uname, pr){
|
||||
const list = (_scope && _scope.project_roles) || PROJECT_ROLES;
|
||||
return '<select class="role-select" title="Job function on the project" '+
|
||||
'onchange="changeProjectRole(\''+uid+'\',this.value,\''+uname+'\')">'+
|
||||
'<option value=""'+(pr?'':' selected')+'>— none —</option>'+
|
||||
list.map(r => '<option value="'+uesc(r)+'"'+(pr===r?' selected':'')+'>'+uesc(r)+'</option>').join('')+
|
||||
// Keep a title that isn't on the list (set via the API or an older record).
|
||||
(pr && list.indexOf(pr) < 0 ? '<option value="'+uesc(pr)+'" selected>'+uesc(pr)+'</option>' : '')+
|
||||
'</select>';
|
||||
}
|
||||
function projRoleReadonly(u, can, why){
|
||||
return '<span'+(can?'':' title="'+uesc(why)+'"')+'>'+uesc(u.project_role || '—')+'</span>';
|
||||
}
|
||||
|
||||
// Per-user project access gets its own column: buried among the action buttons, it
|
||||
// was exactly where you'd fail to find "which projects can this person see, and what
|
||||
// may they do there".
|
||||
function projAccessCell(u){
|
||||
if(normRole(u.role) === 'admin'){
|
||||
return '<span class="tag admin" title="Admins can access every project">all projects</span>';
|
||||
}
|
||||
const n = u.project_count;
|
||||
const label = (n === undefined || n === null) ? 'Projects…'
|
||||
: (n === 0 ? 'No projects yet' : n+' project'+(n===1?'':'s'));
|
||||
if(!u.manageable){
|
||||
return '<span class="note" style="margin:0" title="'+uesc(u.manage_blocked_reason||'')+'">'+uesc(label)+'</span>';
|
||||
}
|
||||
return '<button class="mini'+(n === 0 ? ' danger' : '')+
|
||||
'" onclick="manageProjects(\''+jsq(u.id)+'\',\''+jsq(u.username)+'\')"'+
|
||||
' title="Choose which projects this user can access, and their role on each">'+
|
||||
uesc(label)+'</button>';
|
||||
}
|
||||
|
||||
// ── row actions ───────────────────────────────────────────────────────────────
|
||||
// Each one reloads on failure so a control can never sit there showing a value the
|
||||
// server refused.
|
||||
async function resetPw(id, username){
|
||||
const pw = prompt('New password for "'+username+'" (min 12 characters):');
|
||||
if(pw === null) return;
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/password',{new_password:pw});
|
||||
if(status === 200) alert('Password reset for '+username+'. Their existing sessions are signed out.');
|
||||
else alert('Could not reset the password: '+apiError(status, json));
|
||||
}
|
||||
|
||||
async function toggleActive(id, makeActive){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/active',{is_active:makeActive});
|
||||
if(status === 200) loadUsers();
|
||||
else { alert('Could not change that account: '+apiError(status, json)); loadUsers(); }
|
||||
}
|
||||
|
||||
async function changeRole(id, role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/role',{role});
|
||||
if(status !== 200) alert('Could not change permissions for '+username+': '+apiError(status, json));
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function changeProjectRole(id, project_role, username){
|
||||
const { status, json } = await api('POST','/api/auth/users/'+id+'/project-role',{project_role});
|
||||
if(status !== 200) alert('Could not set the project role for '+username+': '+apiError(status, json));
|
||||
loadUsers();
|
||||
}
|
||||
|
||||
async function deleteUser(id, username){
|
||||
if(!confirm('Delete user "'+username+'"?\n\nTheir account and every project assignment go with it. '+
|
||||
'This cannot be undone — disable the account instead if you only want to block sign-in.')) return;
|
||||
const { status, json } = await api('DELETE','/api/auth/users/'+id);
|
||||
if(status === 200) loadUsers();
|
||||
else alert('Could not delete '+username+': '+apiError(status, json));
|
||||
}
|
||||
|
||||
// ── create ────────────────────────────────────────────────────────────────────
|
||||
function renderCreateForm(){
|
||||
const card = document.getElementById('create-card');
|
||||
if(!card) return;
|
||||
if(!manages()){ card.style.display = 'none'; return; }
|
||||
card.style.display = '';
|
||||
|
||||
const grantable = _scope.grantable_roles || [];
|
||||
const roleSel = document.getElementById('nu-role');
|
||||
roleSel.innerHTML = PERM_ROLES.filter(r => grantable.indexOf(r) >= 0)
|
||||
.map(r => '<option value="'+r+'"'+(r==='project_user'?' selected':'')+'>'+uesc(PERM_LABELS[r])+'</option>').join('');
|
||||
|
||||
const prSel = document.getElementById('nu-project-role');
|
||||
prSel.innerHTML = '<option value="">Project role…</option>'+
|
||||
(_scope.project_roles||PROJECT_ROLES).map(r => '<option value="'+uesc(r)+'">'+uesc(r)+'</option>').join('');
|
||||
|
||||
// The project picker is REQUIRED for a super user and optional for an admin —
|
||||
// because a super user's authority over an account comes from the projects it is
|
||||
// on, so an account created with none is one they instantly cannot manage. The
|
||||
// server refuses that; the form says so up front rather than after a failed save.
|
||||
const admin = _scope.scope === 'all';
|
||||
const projects = _scope.managed_projects || [];
|
||||
document.getElementById('create-sub').innerHTML = admin
|
||||
? 'Creates a login account. Assign projects here or later from <strong>Project access</strong> in the table above.'
|
||||
: 'Creates a login account on your project'+(projects.length===1?'':'s')+
|
||||
'. You administer users per project, so a new account has to start on at least one of them.';
|
||||
document.getElementById('nu-projects-label').innerHTML = admin
|
||||
? 'Projects (optional — you can assign them later)'
|
||||
: 'Projects <strong>*</strong> — pick at least one';
|
||||
const live = projects.filter(p => !p.archived);
|
||||
const list = document.getElementById('nu-project-list');
|
||||
if(!projects.length){
|
||||
list.innerHTML = '<div class="note" style="padding:var(--s2)">You don’t administer any project yet.</div>';
|
||||
} else {
|
||||
// Archived projects are omitted, not disabled: staffing a frozen job is never
|
||||
// what you mean when creating an account, and an admin can still assign one
|
||||
// afterwards from the project-access dialog.
|
||||
list.innerHTML = (live.length ? live : []).map(p =>
|
||||
'<div class="pickrow"><label><input type="checkbox" value="'+uesc(p.id)+'"'+
|
||||
(live.length === 1 ? ' checked' : '')+'>'+
|
||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||
(p.number ? ' <span style="color:var(--muted)">'+uesc(p.number)+'</span>' : '')+'</span></label></div>').join('')
|
||||
|| '<div class="note" style="padding:var(--s2)">Every project you administer is archived.</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function createUser(){
|
||||
const msg = document.getElementById('users-create-msg');
|
||||
const val = id => (document.getElementById(id)||{}).value || '';
|
||||
const username = val('nu-username').trim();
|
||||
const password = val('nu-password');
|
||||
const project_ids = [...document.querySelectorAll('#nu-project-list input[type=checkbox]:checked')]
|
||||
.map(c => c.value);
|
||||
const say = (color, text) => { msg.style.color = color; msg.textContent = text; };
|
||||
if(!username){ say('var(--red)','Username is required.'); return; }
|
||||
if(password.length < 12){ say('var(--red)','Password must be at least 12 characters.'); return; }
|
||||
if(_scope.scope !== 'all' && !project_ids.length){
|
||||
say('var(--red)','Pick at least one project — you administer users per project.'); return;
|
||||
}
|
||||
say('var(--muted)','Creating…');
|
||||
const { status, json } = await api('POST','/api/auth/users',{
|
||||
username, password, project_ids,
|
||||
full_name: val('nu-fullname').trim(), email: val('nu-email').trim(),
|
||||
role: val('nu-role'), project_role: val('nu-project-role'),
|
||||
});
|
||||
if(status === 200){
|
||||
say('var(--green)','✅ Created '+username+'.');
|
||||
['nu-username','nu-fullname','nu-email','nu-password'].forEach(id => document.getElementById(id).value = '');
|
||||
loadUsers();
|
||||
} else {
|
||||
say('var(--red)','❌ '+apiError(status, json, 'Could not create the account'));
|
||||
}
|
||||
}
|
||||
|
||||
// ── project access dialog ─────────────────────────────────────────────────────
|
||||
// For an admin this is the whole of a person's access. For a super user it is their
|
||||
// slice of it: the server returns only the projects they administer and says how many
|
||||
// more the person is on, and a save leaves those others untouched.
|
||||
async function manageProjects(id, username){
|
||||
const { status, json } = await api('GET','/api/auth/users/'+id+'/projects');
|
||||
if(status !== 200 || !json){ alert('Could not load projects: '+apiError(status, json)); return; }
|
||||
openProjectModal(id, username, json);
|
||||
}
|
||||
function closeProjectModal(){ const m = document.getElementById('proj-modal'); if(m) m.remove(); }
|
||||
|
||||
// A project's role dropdown only matters while that project is ticked.
|
||||
function projRowToggled(cb){
|
||||
const row = cb.closest('.pickrow');
|
||||
const sel = row && row.querySelector('select');
|
||||
if(sel) sel.disabled = !cb.checked;
|
||||
}
|
||||
|
||||
function openProjectModal(userId, username, data){
|
||||
closeProjectModal();
|
||||
const projects = (data.projects||[]).slice()
|
||||
// Live jobs first — an archived one is still listed (an existing assignment has
|
||||
// to stay removable) but it is finished work, so it doesn't belong at the top of
|
||||
// a list you're using to staff someone.
|
||||
.sort((a,b) => (a.archived?1:0) - (b.archived?1:0));
|
||||
const assigned = new Set(data.assigned||[]);
|
||||
const roles = data.roles || {};
|
||||
const userObj = data.user || {};
|
||||
const isAdmin = normRole(userObj.role) === 'admin';
|
||||
const acctRole = normRole(userObj.role);
|
||||
const grantable = data.grantable_project_roles || PROJECT_SCOPED_ROLES;
|
||||
|
||||
const items = projects.length ? projects.map(p => {
|
||||
const on = assigned.has(p.id);
|
||||
const cur = roles[p.id] || '';
|
||||
const opts = ['<option value=""'+(cur===''?' selected':'')+'>Same as account ('+
|
||||
uesc(PERM_LABELS[acctRole]||acctRole)+')</option>']
|
||||
.concat(PROJECT_SCOPED_ROLES.filter(r => grantable.indexOf(r) >= 0 || r === cur).map(r =>
|
||||
'<option value="'+r+'"'+(cur===r?' selected':'')+(grantable.indexOf(r)<0?' disabled':'')+'>'+
|
||||
uesc(PERM_LABELS[r])+' here</option>'));
|
||||
return '<div class="pickrow">'+
|
||||
'<label><input type="checkbox" value="'+uesc(p.id)+'"'+(on?' checked':'')+(isAdmin?' disabled':'')+
|
||||
' onchange="projRowToggled(this)">'+
|
||||
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+
|
||||
(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+
|
||||
(p.archived?' <span class="tag archived" title="Archived — read-only until an admin unarchives it">archived</span>':'')+
|
||||
'</span></label>'+
|
||||
'<select class="role-select" data-role-for="'+uesc(p.id)+'"'+(isAdmin||!on?' disabled':'')+'>'+
|
||||
opts.join('')+'</select>'+
|
||||
'</div>';
|
||||
}).join('') : '<div class="note">No projects to choose from.</div>';
|
||||
|
||||
const others = data.other_projects || 0;
|
||||
const intro = isAdmin
|
||||
? '<div class="banner" style="margin:0 0 10px">This user is an <strong>Administrator</strong> and can '+
|
||||
'access every project regardless of assignment.</div>'
|
||||
: '<div class="note" style="margin:0 0 10px">Tick the projects this user may access, and set their role '+
|
||||
'on each. <strong>Project Admin</strong> can delete work packages, change a completed SOP and delete '+
|
||||
'that project; <strong>Project Super User</strong> can also manage that project’s user accounts; '+
|
||||
'<strong>Project User</strong> can do neither. Leave it on <em>Same as account</em> to use their '+
|
||||
'Permissions setting.</div>'+
|
||||
(others ? '<div class="banner warn" style="margin:0 0 10px">Also on '+others+' project'+
|
||||
(others===1?'':'s')+' you don’t administer. Those stay exactly as they are — saving here only '+
|
||||
'changes the projects listed below.</div>' : '');
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'proj-modal';
|
||||
modal.className = 'modal-ov';
|
||||
modal.innerHTML =
|
||||
'<div class="modal-box">'+
|
||||
'<div class="modal-head">Project access & permissions — '+uesc(username)+'</div>'+
|
||||
'<div class="modal-body">'+intro+'<div id="proj-list">'+items+'</div></div>'+
|
||||
'<div class="modal-foot">'+
|
||||
'<button onclick="closeProjectModal()">Cancel</button>'+
|
||||
(isAdmin ? '' : '<button class="primary" id="proj-save">Save</button>')+
|
||||
'</div>'+
|
||||
'</div>';
|
||||
modal.addEventListener('click', e => { if(e.target === modal) closeProjectModal(); });
|
||||
document.body.appendChild(modal);
|
||||
|
||||
const saveBtn = document.getElementById('proj-save');
|
||||
if(saveBtn) saveBtn.onclick = async () => {
|
||||
const ids = [...modal.querySelectorAll('#proj-list input[type=checkbox]:checked')].map(c => c.value);
|
||||
const roleMap = {};
|
||||
ids.forEach(pid => {
|
||||
const sel = modal.querySelector('#proj-list select[data-role-for="'+pid+'"]');
|
||||
if(sel && sel.value) roleMap[pid] = sel.value;
|
||||
});
|
||||
const { status, json } = await api('PUT','/api/auth/users/'+userId+'/projects',
|
||||
{ project_ids: ids, roles: roleMap });
|
||||
if(status === 200){ closeProjectModal(); loadUsers(); }
|
||||
else alert('Save failed: '+apiError(status, json));
|
||||
};
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', e => { if(e.key === 'Escape') closeProjectModal(); });
|
||||
|
||||
// ── start ─────────────────────────────────────────────────────────────────────
|
||||
// auth-guard.js requires a login and publishes window.WP_USER (firing
|
||||
// 'wp-auth-ready'). Unlike the Admin Console there is no role gate here: everyone
|
||||
// signed in gets a directory, and what they can DO comes from the scope call.
|
||||
let _booted = false;
|
||||
function start(){
|
||||
if(_booted || !window.WP_USER) return;
|
||||
_booted = true;
|
||||
boot();
|
||||
}
|
||||
document.addEventListener('wp-auth-ready', start);
|
||||
start();
|
||||
90
html/wp-sidenav.css
Normal file
90
html/wp-sidenav.css
Normal file
@@ -0,0 +1,90 @@
|
||||
/* 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; }
|
||||
221
html/wp-sidenav.js
Normal file
221
html/wp-sidenav.js
Normal file
@@ -0,0 +1,221 @@
|
||||
/* Global app navigation drawer for the Work Package Suite.
|
||||
|
||||
The suite grew page by page and the only way between them was the browser's back
|
||||
button or the home page. This is the one place that lists everywhere you can go —
|
||||
a ☰ button in the app bar opening an off-canvas drawer.
|
||||
|
||||
ROLE GATING: the drawer only offers what the signed-in account can actually reach.
|
||||
The Admin Console is admins-only, so it appears for admins only; the User Directory
|
||||
is readable by everyone (that's the point of a directory), so it always appears.
|
||||
Every destination re-checks server-side — this is navigation, not a permission.
|
||||
|
||||
PROJECT CONTEXT: links that open a project-scoped page carry the active ?project=
|
||||
so the drawer doesn't silently drop the job you were looking at.
|
||||
|
||||
Add it to a page with:
|
||||
<link rel="stylesheet" href="wp-sidenav.css">
|
||||
<script src="wp-sidenav.js"></script>
|
||||
after auth-guard.js. It mounts itself into whichever top bar the page has, and
|
||||
skips iframes (the embedded WP creator lives inside a page that already has one). */
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var inIframe = (function () { try { return window.top !== window.self; } catch (e) { return true; } })();
|
||||
if (inIframe) return;
|
||||
|
||||
// ── the map ────────────────────────────────────────────────────────────────
|
||||
// `match` is what marks a link current; `project` means "carry ?project=".
|
||||
// `show` is an optional gate, evaluated once the user is known.
|
||||
var LINKS = [
|
||||
{ section: 'Work' },
|
||||
{ href: 'index.html', match: /(^|\/)(index\.html)?$/, icon: '⌂', label: 'Home',
|
||||
sub: 'Projects & what\'s next' },
|
||||
{ href: 'work-package-suite.html?tab=sop', match: /work-package-suite\.html/, icon: '⚙',
|
||||
label: 'SOP Configuration', sub: 'The project baseline', project: true, tab: 'sop' },
|
||||
{ href: 'work-package-suite.html?tab=wp', match: null, icon: '▤',
|
||||
label: 'Work Package Creator', sub: 'Build and edit IWPs', project: true, tab: 'wp' },
|
||||
{ href: 'work-package-suite.html?tab=dashboard', match: null, icon: '▦',
|
||||
label: 'Dashboard', sub: 'Status & release gates', project: true, tab: 'dashboard' },
|
||||
{ href: 'field.html', match: /(^|\/)field\.html$/, icon: '⚒', label: 'Field View',
|
||||
sub: 'Update packages on site', project: true },
|
||||
{ section: 'People' },
|
||||
{ href: 'users.html', match: /(^|\/)users\.html$/, icon: '☺', label: 'User Directory',
|
||||
sub: 'Who\'s on the project' },
|
||||
{ href: 'admin.html', match: /(^|\/)admin\.html$/, icon: '⚡', label: 'Admin Console',
|
||||
sub: 'Settings & diagnostics',
|
||||
show: function () { return typeof window.wpIsAdmin === 'function' && window.wpIsAdmin(); } },
|
||||
];
|
||||
|
||||
function esc(v) {
|
||||
return String(v == null ? '' : v)
|
||||
.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
||||
.replace(/"/g, '"').replace(/'/g, ''');
|
||||
}
|
||||
function isDark(node) {
|
||||
try {
|
||||
var m = (getComputedStyle(node).backgroundColor || '').match(/(\d+),\s*(\d+),\s*(\d+)/);
|
||||
if (!m) return true;
|
||||
return (0.299 * +m[1] + 0.587 * +m[2] + 0.114 * +m[3]) < 140;
|
||||
} catch (e) { return true; }
|
||||
}
|
||||
|
||||
function activeProjectId() {
|
||||
try {
|
||||
var q = new URLSearchParams(location.search).get('project');
|
||||
if (q) return q;
|
||||
return (window.ProjectData && ProjectData.getActiveId && ProjectData.getActiveId()) || '';
|
||||
} catch (e) { return ''; }
|
||||
}
|
||||
|
||||
// The suite page reads ?tab= and ?project=; keeping the current project on the link
|
||||
// is the difference between "open the dashboard" and "open the dashboard, then pick
|
||||
// the job again".
|
||||
function hrefFor(item) {
|
||||
if (!item.project) return item.href;
|
||||
var pid = activeProjectId();
|
||||
if (!pid) return item.href;
|
||||
var sep = item.href.indexOf('?') >= 0 ? '&' : '?';
|
||||
return item.href + sep + 'project=' + encodeURIComponent(pid);
|
||||
}
|
||||
|
||||
// Current-page marking. The three suite tabs share one file, so they're told apart
|
||||
// by ?tab= (defaulting to sop, which is what work-package-suite.html itself does).
|
||||
function isCurrent(item) {
|
||||
var path = location.pathname;
|
||||
if (item.tab) {
|
||||
if (!/work-package-suite\.html$/.test(path)) return false;
|
||||
var tab = '';
|
||||
try { tab = new URLSearchParams(location.search).get('tab') || 'sop'; } catch (e) { tab = 'sop'; }
|
||||
return tab === item.tab;
|
||||
}
|
||||
return !!(item.match && item.match.test(path));
|
||||
}
|
||||
|
||||
// ── build ──────────────────────────────────────────────────────────────────
|
||||
var drawer, scrim, btn, lastFocus = null;
|
||||
|
||||
function buildDrawer(user) {
|
||||
scrim = document.createElement('div');
|
||||
scrim.className = 'wp-navscrim';
|
||||
scrim.hidden = true;
|
||||
scrim.addEventListener('click', close);
|
||||
|
||||
drawer = document.createElement('nav');
|
||||
drawer.className = 'wp-sidenav';
|
||||
drawer.id = 'wp-sidenav';
|
||||
drawer.setAttribute('aria-label', 'Suite navigation');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
|
||||
var rows = '';
|
||||
LINKS.forEach(function (item) {
|
||||
if (item.section) { rows += '<div class="wp-sidenav-sect">' + esc(item.section) + '</div>'; return; }
|
||||
if (item.show && !item.show()) return;
|
||||
rows += '<a class="wp-sidenav-link' + (isCurrent(item) ? ' is-current' : '') + '" href="' +
|
||||
esc(hrefFor(item)) + '"' + (isCurrent(item) ? ' aria-current="page"' : '') + '>' +
|
||||
'<span class="wp-sidenav-ico" aria-hidden="true">' + esc(item.icon) + '</span>' +
|
||||
'<span class="wp-sidenav-label">' + esc(item.label) +
|
||||
(item.sub ? '<small>' + esc(item.sub) + '</small>' : '') + '</span></a>';
|
||||
});
|
||||
|
||||
var who = user ? (user.full_name || user.username || '') : '';
|
||||
drawer.innerHTML =
|
||||
'<div class="wp-sidenav-head">' +
|
||||
'<span class="wp-logo-chip"><img src="prime-controls-logo.jpg" alt="Prime Controls"></span>' +
|
||||
'<span class="wp-sidenav-title">Work Package Suite<span>Prime Controls</span></span>' +
|
||||
'<button type="button" class="wp-sidenav-close" title="Close" aria-label="Close navigation">✕</button>' +
|
||||
'</div>' +
|
||||
'<div class="wp-sidenav-body">' + rows + '</div>' +
|
||||
'<div class="wp-sidenav-foot">' +
|
||||
(who ? '<div class="wp-sidenav-who">Signed in as<strong>' + esc(who) + '</strong></div>' : '') +
|
||||
'<button type="button" class="wp-sidenav-link" id="wp-sidenav-signout">' +
|
||||
'<span class="wp-sidenav-ico" aria-hidden="true">⏻</span>' +
|
||||
'<span class="wp-sidenav-label">Sign out</span></button>' +
|
||||
'</div>';
|
||||
|
||||
drawer.querySelector('.wp-sidenav-close').addEventListener('click', close);
|
||||
drawer.querySelector('#wp-sidenav-signout').addEventListener('click', function () {
|
||||
if (typeof window.wpLogout === 'function') window.wpLogout();
|
||||
});
|
||||
document.body.appendChild(scrim);
|
||||
document.body.appendChild(drawer);
|
||||
}
|
||||
|
||||
function focusables() {
|
||||
return drawer ? drawer.querySelectorAll('a[href], button:not([disabled])') : [];
|
||||
}
|
||||
|
||||
function open() {
|
||||
if (!drawer) return;
|
||||
lastFocus = document.activeElement;
|
||||
scrim.hidden = false;
|
||||
// Two frames: the element has to be laid out un-transitioned before the class
|
||||
// that animates it lands, or it simply appears.
|
||||
requestAnimationFrame(function () {
|
||||
scrim.classList.add('is-open');
|
||||
drawer.classList.add('is-open');
|
||||
});
|
||||
drawer.setAttribute('aria-hidden', 'false');
|
||||
btn.setAttribute('aria-expanded', 'true');
|
||||
var f = focusables();
|
||||
if (f.length) f[0].focus();
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!drawer) return;
|
||||
drawer.classList.remove('is-open');
|
||||
scrim.classList.remove('is-open');
|
||||
drawer.setAttribute('aria-hidden', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
// Keep the scrim in the tree until the slide-out finishes, or the panel snaps.
|
||||
setTimeout(function () { if (!drawer.classList.contains('is-open')) scrim.hidden = true; }, 220);
|
||||
if (lastFocus && lastFocus.focus) lastFocus.focus();
|
||||
}
|
||||
|
||||
function isOpen() { return !!(drawer && drawer.classList.contains('is-open')); }
|
||||
|
||||
// Escape closes; Tab cycles inside the drawer while it's open, so focus can't walk
|
||||
// off into the page behind the scrim.
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!isOpen()) return;
|
||||
if (e.key === 'Escape') { e.preventDefault(); close(); return; }
|
||||
if (e.key !== 'Tab') return;
|
||||
var f = focusables();
|
||||
if (!f.length) return;
|
||||
var first = f[0], last = f[f.length - 1];
|
||||
if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); }
|
||||
else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); }
|
||||
});
|
||||
|
||||
// ── mount ──────────────────────────────────────────────────────────────────
|
||||
// The button goes at the START of the bar, before the brand: that is where a menu
|
||||
// affordance is looked for, and it keeps clear of the project switcher and search
|
||||
// that wp-chrome.js inserts into the middle of the same bar.
|
||||
function mount() {
|
||||
if (document.getElementById('wp-sidenav')) return;
|
||||
var host = document.querySelector('.wp-appbar') || document.querySelector('.header');
|
||||
if (!host) return;
|
||||
|
||||
btn = document.createElement('button');
|
||||
btn.type = 'button';
|
||||
btn.className = 'wp-navbtn';
|
||||
btn.id = 'wp-navbtn';
|
||||
btn.title = 'Menu';
|
||||
btn.setAttribute('aria-label', 'Open navigation');
|
||||
btn.setAttribute('aria-haspopup', 'true');
|
||||
btn.setAttribute('aria-expanded', 'false');
|
||||
btn.setAttribute('aria-controls', 'wp-sidenav');
|
||||
if (!isDark(host)) btn.setAttribute('data-bar', 'light');
|
||||
btn.innerHTML = '<svg viewBox="0 0 20 20" width="20" height="20" aria-hidden="true">' +
|
||||
'<path d="M3 5.5h14M3 10h14M3 14.5h14" fill="none" stroke="currentColor" ' +
|
||||
'stroke-width="1.6" stroke-linecap="round"/></svg>';
|
||||
btn.addEventListener('click', function () { if (isOpen()) close(); else open(); });
|
||||
|
||||
host.insertBefore(btn, host.firstChild);
|
||||
buildDrawer(window.WP_USER);
|
||||
}
|
||||
|
||||
// Wait for the auth guard: the gated links depend on the signed-in role, and an
|
||||
// unauthenticated page is about to redirect anyway.
|
||||
if (window.WP_USER) mount();
|
||||
else document.addEventListener('wp-auth-ready', mount);
|
||||
})();
|
||||
Reference in New Issue
Block a user