diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md
index 1e66586..bd124a5 100644
--- a/DEPLOYMENT.md
+++ b/DEPLOYMENT.md
@@ -446,3 +446,26 @@ For a quick local look, the API falls back to a SQLite file when `DATABASE_URL`
is unset (`sqlite:///./wpsuite.db`) — see [`server/README.md`](server/README.md)
§ *Local dev*. The front end alone can also be served statically from `html/`
(it falls back to browser storage when the API isn't reachable).
+
+## Per-project permissions
+
+`users.role` is the account's **default** permissions role. A membership row can
+override it **per project** (`project_members.role`), so someone can be Project
+Admin on one job and a plain Project User on another. Empty means "inherit the
+account's role", which is how every pre-existing membership behaves.
+
+Resolved by `effective_role()` in `server/app.py`; `require_project_admin()` uses it,
+so deleting a work package, changing a completed SOP and deleting a project are all
+judged **on that project**. An app `admin` is admin everywhere and bypasses
+membership entirely.
+
+Set it in **Admin console → User administration → Project access** (its own column,
+showing how many projects each account can reach). The dialog ticks project access
+and picks the role on each; `/api/auth/users/{id}/projects` takes
+`{project_ids: [...], roles: {project_id: role}}` and only accepts the two
+project-scoped roles. Changes are audit-logged as `project_access_changed`.
+
+**Who appears in the SOP's people pickers** is `GET /api/projects/{id}/members` —
+the project's members plus app admins, each with their effective role on that
+project. A project with nobody assigned shows only the admins, which is why
+assigning people is the first step on a new job.
diff --git a/html/admin.js b/html/admin.js
index 93ee087..c9fe7ff 100644
--- a/html/admin.js
+++ b/html/admin.js
@@ -178,6 +178,9 @@ async function loadUsers(){
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.
@@ -197,6 +200,42 @@ function fillProjectRoleOptions(){
PROJECT_ROLES.map(r=>'').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 = uesc(u.username).replace(/'/g, "\\'");
+ if(normRole(u.role) === 'admin'){
+ return 'all projects';
+ }
+ const n = _userProjectCounts[u.id];
+ const label = (n === undefined) ? 'Projects…'
+ : (n === 0 ? 'No projects yet' : n + ' project' + (n === 1 ? '' : 's'));
+ return '';
+}
+
+// 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='
This user is an admin and can access every project regardless of assignment.
' : '
Tick the projects this user may access.
')+
+ (isAdmin ? '
This user is an Administrator and can access every project regardless of assignment.
'
+ : '
Tick the projects this user may access, and set their role on each. '+
+ 'Project Admin can delete work packages, change a completed SOP and delete that project; '+
+ 'Project User cannot. Leave it on Same as account to use their Permissions setting.
')+
'
'+items+'
'+
'
'+
'
'+
@@ -364,8 +424,13 @@ function openProjectModal(userId, username, projects, assigned, userObj){
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 { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
- if(status===200) closeProjectModal();
+ 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+').');
};
}
diff --git a/html/sw.js b/html/sw.js
index 7fce40f..fc74eb8 100644
--- a/html/sw.js
+++ b/html/sw.js
@@ -13,7 +13,7 @@
'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-v2';
+const CACHE = 'wp-suite-shell-v3';
const SHELL = [
'/', '/index.html', '/work-package-suite.html', '/wp-creation-index.html',
'/field.html', '/login.html', '/admin.html',
diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js
index c642264..3812046 100644
--- a/html/work-package-suite-app.js
+++ b/html/work-package-suite-app.js
@@ -342,19 +342,25 @@ function loadSampleData(){
document.getElementById('proj_division').value = 'Semiconductor';
document.getElementById('proj_site').value = 'Boise, ID — Fab 7';
- // Populate Step 2
- document.getElementById('proj_pm').value = 'Mariano Sanchez';
- document.getElementById('proj_apm').value = 'Assistant PM';
- document.getElementById('proj_cm').value = 'K. Boyd';
- document.getElementById('proj_qm').value = 'D. Nguyen';
+ // Populate Step 2. The leadership slots are account pickers now, so the sample's
+ // fictional names can't be "selected" — setting .value on a
`).join('');
@@ -1019,17 +1105,19 @@ function collectStepData(){
state.project.site = document.getElementById('proj_site').value;
break;
case 2:
- state.team.pm = document.getElementById('proj_pm').value;
- state.team.apm = document.getElementById('proj_apm').value;
- state.team.cm = document.getElementById('proj_cm').value;
- state.team.qm = document.getElementById('proj_qm').value;
+ // These are user-account pickers now, so their .value is an ID, not a name.
+ // Reading them straight into state.team would put an id where the display
+ // name belongs (and it would then print on the SOP as `user_ab12…`).
+ syncTeamFromPickers();
break;
case 3:
// The two required roles now have editable titles (default Superintendent/Foreman).
state.signoffRoles[0].role = (document.getElementById('role_super_title').value || 'Role 1').trim();
- state.signoffRoles[0].name = document.getElementById('role_super_name').value;
- state.signoffRoles[1].role = (document.getElementById('role_foreman_title').value || 'Role 2').trim();
- state.signoffRoles[1].name = document.getElementById('role_foreman_name').value;
+ // Titles are still free text; the NAMES are account pickers whose .value is
+ // an id, so they're maintained by their own onchange (see
+ // renderSignoffRolePickers) rather than read as text here.
+ state.signoffRoles[0].role = document.getElementById('role_super_title').value || 'Superintendent';
+ state.signoffRoles[1].role = document.getElementById('role_foreman_title').value || 'Foreman';
break;
case 5:
state.governance.woformat = document.getElementById('gov_woformat').value;
@@ -1113,7 +1201,10 @@ function completeSOP(){
site: state.project.site,
teamMembers: state.teamMembers.filter(m=>(m.role||m.name||m.userId))
},
- roles: state.signoffRoles.filter(r=>r.role),
+ roles: state.signoffRoles.filter(r=>r.role).map(r=>({
+ role: r.role, name: r.name || '',
+ userId: r.userId || '' // who signs — an account, so it can be notified
+ })),
governance: {
issuance: state.governance.issuance.length ? state.governance.issuance : ['By Sector / Area'],
woSize: state.governance.wosize,
diff --git a/html/work-package-suite-styles.css b/html/work-package-suite-styles.css
index 0983eb6..3dba4ba 100644
--- a/html/work-package-suite-styles.css
+++ b/html/work-package-suite-styles.css
@@ -167,15 +167,54 @@ body {
.tab-icon { font-size: 16px; }
-/* CONTENT AREA */
+/* CONTENT AREA
+ The SOP wizard reads better with a bound on line length, but 1000px on a 1920
+ screen wasted half the display — and it also squeezed the embedded Work Package
+ Creator (an iframe living in here) into a ~930px column with its own scrollbar
+ inside the page's. Wider cap for the wizard; the embedded tools go full-bleed
+ (see .content-area.embed-full below). */
.content-area {
flex: 1;
padding: 2rem;
- max-width: 1000px;
+ max-width: 1700px;
margin: 0 auto;
width: 100%;
}
+/* Work Package Creation / Dashboard: the iframe fills the window below the app
+ chrome and owns the only scrollbar, so the creator's sticky save bar and
+ navigator drawer position against a real viewport instead of scrolling away. */
+.content-area.embed-full {
+ /* `flex: none` matters: .content-area is a column flex item with `flex: 1`, whose
+ flex-basis:0% overrides `height` and leaves the used height INDEFINITE — so a
+ child's `height:100%` resolves to auto and the iframe collapses to its 150px
+ default. Opting out of flex sizing makes the height definite. */
+ flex: none;
+ max-width: none;
+ padding: 0;
+ height: calc(100vh - var(--wp-chrome-h, 96px));
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+.content-area.embed-full > .tool.active {
+ flex: 1 1 auto;
+ min-height: 0; /* let it shrink instead of overflowing the shell */
+ height: 100%;
+}
+#wp-frame {
+ width: 100%;
+ border: 0;
+ min-height: calc(100vh - 200px);
+}
+#wp-frame.fill {
+ display: block;
+ height: 100%;
+ min-height: 0;
+}
+/* No page scrollbar while a full-bleed tool is open — the iframe scrolls. */
+body.embed-full { overflow: hidden; }
+
.tool {
display: none;
}
@@ -242,10 +281,15 @@ body {
border-left: 4px solid var(--primary);
}
-/* FIELDS */
+/* FIELDS
+ The wizard's fields were one per row, which looked right in a 1000px column but
+ stretches a text input across the screen now that the content area is wide. Flow
+ them into as many ~340px columns as fit; `.col1` still forces a single column for
+ the fields that genuinely want the width (long text, textareas). */
.field-grid {
display: grid;
gap: 1.5rem;
+ grid-template-columns: repeat(auto-fit, minmax(340px, 1fr));
}
.field-grid.col1 { grid-template-columns: 1fr; }
diff --git a/html/work-package-suite.html b/html/work-package-suite.html
index 6232d86..5ec603d 100644
--- a/html/work-package-suite.html
+++ b/html/work-package-suite.html
@@ -145,14 +145,14 @@
*
-
+
*
-
+
@@ -372,7 +372,7 @@
-
+
diff --git a/html/wp-creation-app.js b/html/wp-creation-app.js
index 3a650db..1c813bf 100644
--- a/html/wp-creation-app.js
+++ b/html/wp-creation-app.js
@@ -1322,9 +1322,10 @@ function buildSectionNav(){
function positionSectionNav(){
const nav=document.getElementById('section-nav'), hdr=document.querySelector('.header');
if(nav && hdr) nav.style.top = hdr.offsetHeight + 'px';
- // The WP navigator rail sticks below the header + section-nav chrome.
- const top=(hdr?hdr.offsetHeight:0)+((nav && nav.style.display!=='none')?nav.offsetHeight:0);
- document.documentElement.style.setProperty('--rail-top', top+'px');
+ // The navigator drawer + its handle hang below the page header. Deliberately NOT
+ // including the section-nav height: that bar is sticky, so at scroll 0 it sits
+ // further down the page and the handle would float over the chrome.
+ document.documentElement.style.setProperty('--rail-top', (hdr?hdr.offsetHeight:0)+'px');
}
let _snLastY=0, _snBound=false;
function initSectionNavAutoHide(){
@@ -1377,14 +1378,85 @@ function viewPackage(i){ if(savedPackages[i]) renderPackage(savedPackages[i]); }
// Every saved package on this project, grouped by status, filterable. Clicking a
// row opens it in the form (same path as the Saved table's "edit").
const WPNAV_ORDER=['Issue','In Progress','Issued','QC','Scheduled','Draft','Closed'];
+// ── drawer behaviour ─────────────────────────────────────────────────────────
+// Hover the edge handle to open, move the pointer away to close. Pinning keeps it
+// open and shifts the form across; the pin is remembered. Touch devices have no
+// hover, so tapping the handle opens it and it stays until you pick something,
+// tap outside, or press Escape.
+let _navCloseTimer = null;
+
+function openWpNav(){
+ clearTimeout(_navCloseTimer);
+ document.body.classList.add('wp-nav-open');
+ const h = document.getElementById('wp-nav-handle');
+ if(h) h.setAttribute('aria-expanded', 'true');
+}
+function closeWpNav(force){
+ clearTimeout(_navCloseTimer);
+ if(navPinned() && !force) return; // pinned stays put unless explicitly closed
+ if(force) setNavPinned(false);
+ document.body.classList.remove('wp-nav-open');
+ const h = document.getElementById('wp-nav-handle');
+ if(h) h.setAttribute('aria-expanded', 'false');
+}
function toggleWpNav(){
- document.body.classList.toggle('wp-nav-collapsed');
- try{ localStorage.setItem('wp_nav_collapsed', document.body.classList.contains('wp-nav-collapsed')?'1':''); }catch(e){}
+ if(document.body.classList.contains('wp-nav-open') || navPinned()) closeWpNav(true);
+ else openWpNav();
+}
+function navPinned(){ return document.body.classList.contains('wp-nav-pinned'); }
+function setNavPinned(on){
+ document.body.classList.toggle('wp-nav-pinned', !!on);
+ const btn = document.getElementById('wp-nav-pin');
+ if(btn){
+ btn.classList.toggle('is-on', !!on);
+ btn.title = on ? 'Unpin (let it hide again)' : 'Keep this list open';
+ }
+ try{ localStorage.setItem('wp_nav_pinned', on ? '1' : ''); }catch(e){}
+ positionSectionNav(); // pinned changes nothing about --rail-top, but keep them in step
+}
+function toggleWpNavPin(){
+ const on = !navPinned();
+ setNavPinned(on);
+ if(on) openWpNav(); else closeWpNav(true);
+ track(on ? 'wp_nav_pinned' : 'wp_nav_unpinned');
+}
+
+function initWpNavDrawer(){
+ const nav = document.getElementById('wp-nav');
+ const handle = document.getElementById('wp-nav-handle');
+ if(!nav || !handle || nav.dataset.bound) return;
+ nav.dataset.bound = '1';
+
+ // Hover in / out. A short close delay stops the drawer snapping shut while the
+ // pointer crosses the gap between handle and panel.
+ const armClose = () => {
+ clearTimeout(_navCloseTimer);
+ _navCloseTimer = setTimeout(() => closeWpNav(false), 350);
+ };
+ handle.addEventListener('mouseenter', openWpNav);
+ handle.addEventListener('mouseleave', armClose);
+ nav.addEventListener('mouseenter', () => clearTimeout(_navCloseTimer));
+ nav.addEventListener('mouseleave', armClose);
+ // Opening from the keyboard should work too.
+ handle.addEventListener('focus', openWpNav);
+
+ document.addEventListener('keydown', e => { if(e.key === 'Escape') closeWpNav(false); });
+ // Tap/click outside closes it (the touch path, where there's no mouseleave).
+ document.addEventListener('click', e => {
+ if(navPinned()) return;
+ if(!document.body.classList.contains('wp-nav-open')) return;
+ if(nav.contains(e.target) || handle.contains(e.target)) return;
+ closeWpNav(false);
+ });
+
+ try{ if(localStorage.getItem('wp_nav_pinned')) setNavPinned(true); }catch(e){}
}
function renderWpNav(){
const list=document.getElementById('wp-nav-list'); if(!list) return;
const cnt=document.getElementById('wp-nav-count');
if(cnt) cnt.textContent = savedPackages.length ? '('+savedPackages.length+')' : '';
+ const badge=document.getElementById('wp-nav-handle-count');
+ if(badge) badge.textContent = savedPackages.length;
const q=((document.getElementById('wp-nav-search')||{}).value||'').trim().toLowerCase();
// Keep the original index — edit/view act on savedPackages by position.
const rows=savedPackages.map((p,i)=>({p:p,i:i})).filter(r=>{
@@ -1422,6 +1494,7 @@ function renderWpNav(){
}
function wpNavOpen(i){
const p=savedPackages[i]; if(!p) return;
+ closeWpNav(false); // get out of the way once you've chosen (unless pinned)
editingId=p.id;
loadPackageIntoForm(p); // ends in showForm(), so this also leaves the dashboard / package view
renderWpNav();
@@ -1930,7 +2003,7 @@ function bootData(){
bootSOP();
setRadio('status','Draft');
loadMembers();
- try{ if(localStorage.getItem('wp_nav_collapsed')) document.body.classList.add('wp-nav-collapsed'); }catch(e){}
+ initWpNavDrawer();
renderSavedList();
positionSectionNav();
cmtInit();
diff --git a/html/wp-creation-index.html b/html/wp-creation-index.html
index a99f0f4..c4483e7 100644
--- a/html/wp-creation-index.html
+++ b/html/wp-creation-index.html
@@ -47,11 +47,17 @@
-
+
+
-
diff --git a/html/wp-creation-styles.css b/html/wp-creation-styles.css
index c190dd0..b12a631 100644
--- a/html/wp-creation-styles.css
+++ b/html/wp-creation-styles.css
@@ -100,10 +100,13 @@
.step-num { display: block; font-size: 9px; opacity: .65; margin-bottom: 2px; }
/* ── MAIN ──
- The form sits in a wide two-column shell: a sticky work-package navigator on
- the left and the form itself filling the rest of the screen. */
- .wp-layout { display: flex; align-items: flex-start; gap: 0; max-width: 1760px; margin: 0 auto; }
- .main { flex: 1 1 auto; min-width: 0; max-width: none; margin: 0; padding: 28px 32px 64px; }
+ The form uses the full width it's given. The work-package navigator is an
+ auto-hiding overlay drawer (see below) rather than a column, so it never takes
+ width away from the form — which matters most when this page is embedded in the
+ suite's tab and every pixel is shared with the app chrome. */
+ .wp-layout { display: block; width: 100%; margin: 0; }
+ .main { min-width: 0; max-width: none; margin: 0; padding: 22px 28px 72px var(--gutter,34px);
+ transition: padding-left .18s ease; }
.section { display: none; }
.section.active { display: block; animation: fade .25s ease; }
@@ -435,7 +438,7 @@
border-radius:var(--radius); padding:7px 10px; font-size:11px; }
/* ── CREATION TOOL ───────────────────────────────────────────────── */
- .ctx-bar { max-width:1760px; margin:0 auto; padding:12px 28px; display:flex; align-items:center; gap:20px;
+ .ctx-bar { max-width:none; margin:0; padding:12px 28px 12px var(--gutter,34px); display:flex; align-items:center; gap:20px;
border-bottom:1px solid var(--border); background:var(--surface); flex-wrap:wrap; }
.ctx-empty { color:var(--text-muted); font-size:13px; }
.ctx-main .ctx-proj { font-weight:700; color:var(--text); font-size:14px; }
@@ -447,7 +450,7 @@
.ctx-meta code { background:var(--surface2); padding:1px 6px; border-radius:3px; color:var(--accent); }
.link-btn { background:none; border:none; color:var(--accent); cursor:pointer; font-size:inherit; padding:0; text-decoration:underline; }
- .mode-wrap { max-width:1760px; margin:0 auto; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
+ .mode-wrap { max-width:none; margin:0; padding:16px 28px 0; display:flex; align-items:center; gap:16px; }
.mode-toggle { display:inline-flex; border:1px solid var(--border-strong); border-radius:6px; overflow:hidden; }
.mode-btn { padding:8px 18px; font-family:var(--sans); font-size:13px; font-weight:600; border:none; background:var(--surface);
color:var(--text-muted); cursor:pointer; }
@@ -471,7 +474,7 @@
/* ── WORK PACKAGE FORM ───────────────────────────────────────────── */
.sop-hint { color:var(--accent) !important; }
- .release-banner { max-width:1760px; margin:0 auto; padding:0 28px; }
+ .release-banner { max-width:none; margin:0; padding:0 28px 0 var(--gutter,34px); }
.release-banner .rb-inner { margin-top:14px; border-radius:var(--radius); padding:11px 16px; font-size:13px; font-weight:600;
display:flex; align-items:center; gap:10px; }
.rb-ready { background:var(--accent-green-dim); color:var(--accent-green); border:1px solid #b6e3c6; }
@@ -579,7 +582,7 @@
/* Section nav (jump chips) */
.section-nav-bar{ position:sticky; top:0; z-index:30; display:flex; flex-wrap:wrap; gap:6px;
- padding:8px 12px; background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
+ padding:8px 12px 8px var(--gutter,34px); background:rgba(255,255,255,.94); backdrop-filter:blur(4px);
border-bottom:1px solid var(--border); box-shadow:0 1px 4px rgba(20,30,50,.06);
transition:transform .22s ease; }
.section-nav-bar:empty{ display:none; }
@@ -588,49 +591,6 @@
border:1px solid var(--border); border-radius:14px; padding:4px 11px; cursor:pointer; white-space:nowrap; }
.sec-chip:hover{ border-color:var(--accent); color:var(--accent); }
- /* ── WP NAVIGATOR (left rail) ─────────────────────────────────────────
- Sticky list of every work package on the project. Click one to open it in
- the form; the one being edited is highlighted. */
- .wp-nav { flex:0 0 262px; width:262px; align-self:flex-start; position:sticky; top:var(--rail-top,0px);
- height:calc(100vh - var(--rail-top,0px)); display:flex; flex-direction:column;
- background:var(--surface); border-right:1px solid var(--border); }
- .wp-nav-head { display:flex; align-items:center; gap:8px; padding:14px 14px 8px; }
- .wp-nav-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); }
- .wp-nav-count { color:var(--text-dim); font-weight:600; letter-spacing:0; }
- .wp-nav-collapse, .wp-nav-reopen { background:transparent; border:1px solid var(--border); border-radius:4px;
- color:var(--text-muted); cursor:pointer; font-size:14px; line-height:1; padding:2px 7px; margin-left:auto; }
- .wp-nav-collapse:hover, .wp-nav-reopen:hover { border-color:var(--accent); color:var(--accent); }
- .wp-nav-search { margin:0 14px 10px; padding:6px 9px; font-size:12px; font-family:inherit;
- border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text); }
- .wp-nav-search:focus { outline:none; border-color:var(--accent); }
- .wp-nav-list { flex:1 1 auto; overflow-y:auto; padding:0 8px 8px; }
- .wp-nav-group { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
- color:var(--text-dim); padding:10px 6px 5px; }
- .wp-nav-item { display:block; width:100%; text-align:left; background:transparent; border:0;
- border-left:3px solid transparent; border-radius:4px; padding:6px 8px; cursor:pointer;
- font-family:inherit; color:var(--text); }
- .wp-nav-item:hover { background:var(--surface2); }
- .wp-nav-item.active { background:var(--accent-dim); border-left-color:var(--accent); }
- .wp-nav-num { display:block; font-family:var(--mono); font-size:11px; font-weight:600; color:var(--accent); }
- .wp-nav-subj { display:block; font-size:12px; color:var(--text-muted); line-height:1.35;
- overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
- .wp-nav-meta { display:flex; align-items:center; gap:6px; margin-top:3px; font-size:10px; color:var(--text-dim); }
- .wp-nav-dot { width:7px; height:7px; border-radius:50%; background:var(--text-dim); flex:0 0 auto; }
- .wp-nav-dot.ok { background:var(--accent-green); }
- .wp-nav-dot.open { background:var(--accent-amber); }
- .wp-nav-dot.hold { background:var(--red); }
- .wp-nav-empty { padding:12px 8px; font-size:12px; color:var(--text-dim); }
- .wp-nav-foot { border-top:1px solid var(--border); padding:9px 12px; display:flex; gap:8px; flex-wrap:wrap; }
- .wp-nav-reopen { display:none; position:sticky; top:var(--rail-top,0px); margin:10px 0 0 8px; align-self:flex-start; z-index:20; }
- /* Keep the rail's footer clear of the fixed save bar. */
- body.has-sticky-save .wp-nav { height:calc(100vh - var(--rail-top,0px) - 56px); }
- body.wp-nav-collapsed .wp-nav { display:none; }
- body.wp-nav-collapsed .wp-nav-reopen { display:block; }
- @media (max-width: 1100px) {
- .wp-nav { display:none; }
- .wp-nav-reopen { display:none !important; }
- }
-
/* ── SOP-inherited marker ───────────────────────────────────────────────────
The "from SOP types" subtext used to sit under the field. It's now a small
chip on the label with the detail in a hover tooltip (site comment 8/3).
@@ -652,7 +612,7 @@
.sop-chip:hover::after, .sop-chip:hover::before,
.sop-chip:focus::after, .sop-chip:focus::before { opacity:1; }
- /* ── people picker (Assignees / Distribution) ───────────────────────────────
+ /* ── people picker (Assignees / Distribution / Predecessors) ─────────────────
Multi-select over the SOP project team instead of a free-text list. */
.people-pick { border:1px solid var(--border-strong); border-radius:4px; background:var(--surface);
padding:5px 6px; min-height:38px; display:flex; flex-wrap:wrap; gap:5px; align-items:center; }
@@ -689,6 +649,106 @@
font-weight:700; letter-spacing:.02em; color:var(--red); background:var(--red-dim);
border:1px solid #ffc4c4; white-space:nowrap; vertical-align:middle; }
+ /* ── WP NAVIGATOR — auto-hiding drawer ─────────────────────────────────────
+ Was a fixed 262px column, which (a) stole width from the form and (b) vanished
+ entirely under 1100px — so embedded in the suite tab it was never visible at
+ all. Now it slides in over the content from a slim edge handle: hover (or tap)
+ the handle to open, move away to close, or pin it open if you'd rather it stay.
+ Nothing is taken from the form unless you pin it. */
+ .wp-nav {
+ position: fixed;
+ top: var(--rail-top, 0px);
+ left: 0;
+ bottom: 0;
+ width: 300px;
+ max-width: 86vw;
+ z-index: 120;
+ display: flex;
+ flex-direction: column;
+ background: var(--surface);
+ border-right: 1px solid var(--border-strong);
+ box-shadow: 6px 0 22px rgba(20,30,50,.16);
+ transform: translateX(-100%);
+ transition: transform .18s ease;
+ }
+ body.wp-nav-open .wp-nav,
+ body.wp-nav-pinned .wp-nav { transform: none; }
+ /* Pinned: no shadow (it's part of the layout now) and the form shifts over. */
+ body.wp-nav-pinned .wp-nav { box-shadow: none; }
+
+ /* The always-visible edge handle. Vertical so it costs ~26px of width. */
+ .wp-nav-handle {
+ position: fixed;
+ top: calc(var(--rail-top, 0px) + 14px);
+ left: 0;
+ z-index: 119;
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding: 12px 5px;
+ writing-mode: vertical-rl;
+ background: var(--surface);
+ color: var(--text-muted);
+ border: 1px solid var(--border-strong);
+ border-left: 0;
+ border-radius: 0 5px 5px 0;
+ box-shadow: 2px 0 8px rgba(20,30,50,.10);
+ font: inherit;
+ font-size: 11px;
+ font-weight: 700;
+ letter-spacing: .08em;
+ text-transform: uppercase;
+ cursor: pointer;
+ }
+ .wp-nav-handle:hover { color: var(--accent); border-color: var(--accent); }
+ .wp-nav-handle .wp-nav-handle-count {
+ writing-mode: horizontal-tb; font-size: 10px; font-weight: 700; letter-spacing: 0;
+ background: var(--accent-dim); color: var(--accent); border-radius: 8px; padding: 0 5px;
+ }
+ body.wp-nav-open .wp-nav-handle,
+ body.wp-nav-pinned .wp-nav-handle { display: none; }
+
+ .wp-nav-head { display:flex; align-items:center; gap:8px; padding:12px 12px 8px; }
+ .wp-nav-title { font-size:12px; font-weight:700; letter-spacing:.06em; text-transform:uppercase; color:var(--text-muted); }
+ .wp-nav-count { color:var(--text-dim); font-weight:600; letter-spacing:0; }
+ .wp-nav-btn { background:transparent; border:1px solid var(--border); border-radius:4px;
+ color:var(--text-muted); cursor:pointer; font-size:13px; line-height:1; padding:3px 7px; }
+ .wp-nav-btn:hover { border-color:var(--accent); color:var(--accent); }
+ .wp-nav-btn.is-on { border-color:var(--accent); color:var(--accent); background:var(--accent-dim); }
+ .wp-nav-head .wp-nav-btn:first-of-type { margin-left:auto; }
+ .wp-nav-search { margin:0 12px 10px; padding:6px 9px; font-size:12px; font-family:inherit;
+ border:1px solid var(--border); border-radius:4px; background:var(--bg); color:var(--text); }
+ .wp-nav-search:focus { outline:none; border-color:var(--accent); }
+ .wp-nav-list { flex:1 1 auto; overflow-y:auto; padding:0 8px 8px; }
+ .wp-nav-group { font-size:10px; font-weight:700; letter-spacing:.09em; text-transform:uppercase;
+ color:var(--text-dim); padding:10px 6px 5px; }
+ .wp-nav-item { display:block; width:100%; text-align:left; background:transparent; border:0;
+ border-left:3px solid transparent; border-radius:4px; padding:6px 8px; cursor:pointer;
+ font-family:inherit; color:var(--text); }
+ .wp-nav-item:hover { background:var(--surface2); }
+ .wp-nav-item.active { background:var(--accent-dim); border-left-color:var(--accent); }
+ .wp-nav-num { display:block; font-family:var(--mono); font-size:11px; font-weight:600; color:var(--accent); }
+ .wp-nav-subj { display:block; font-size:12px; color:var(--text-muted); line-height:1.35;
+ overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
+ .wp-nav-meta { display:flex; align-items:center; gap:6px; margin-top:3px; font-size:10px; color:var(--text-dim); }
+ .wp-nav-dot { width:7px; height:7px; border-radius:50%; background:var(--text-dim); flex:0 0 auto; }
+ .wp-nav-dot.ok { background:var(--accent-green); }
+ .wp-nav-dot.open { background:var(--accent-amber); }
+ .wp-nav-dot.hold { background:var(--red); }
+ .wp-nav-empty { padding:12px 8px; font-size:12px; color:var(--text-dim); }
+ .wp-nav-foot { border-top:1px solid var(--border); padding:9px 12px; display:flex; gap:8px; flex-wrap:wrap; }
+ /* Keep the drawer's footer clear of the fixed save bar. */
+ body.has-sticky-save .wp-nav { bottom: 54px; }
+ /* Pinned: the page chrome shifts with the form so nothing hides behind the panel. */
+ body.wp-nav-pinned { --gutter: 328px; }
+ body.wp-nav-pinned .sticky-save { left: 300px; }
+
+ /* Narrow screens: never pin (there isn't the width) — overlay only. */
+ @media (max-width: 900px) {
+ body.wp-nav-pinned { --gutter: 34px; } /* no room to pin — overlay only */
+ .wp-nav { width: 290px; }
+ }
+
/* Sticky save bar */
.sticky-save{ position:fixed; left:0; right:0; bottom:0; z-index:40; display:flex; align-items:center;
justify-content:space-between; gap:14px; padding:10px 20px; background:#fff;
diff --git a/server/alembic/versions/d15b8c4ef207_per_project_member_role.py b/server/alembic/versions/d15b8c4ef207_per_project_member_role.py
new file mode 100644
index 0000000..f2bcd38
--- /dev/null
+++ b/server/alembic/versions/d15b8c4ef207_per_project_member_role.py
@@ -0,0 +1,28 @@
+"""per-project member role
+
+Lets someone be Project Admin on one job and a normal Project User on another.
+Empty string means "inherit the account's own role" (users.role), which is exactly
+how every existing membership behaved, so this is a no-op for current data.
+
+Revision ID: d15b8c4ef207
+Revises: c93f2b1d7e04
+Create Date: 2026-08-03 17:58:22.401118
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+# revision identifiers, used by Alembic.
+revision = 'd15b8c4ef207'
+down_revision = 'c93f2b1d7e04'
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+ op.add_column('project_members', sa.Column('role', sa.String(length=20),
+ nullable=False, server_default=''))
+
+
+def downgrade() -> None:
+ op.drop_column('project_members', 'role')
diff --git a/server/app.py b/server/app.py
index e51bd60..a3fe7dd 100644
--- a/server/app.py
+++ b/server/app.py
@@ -137,16 +137,36 @@ def require_project_access(db: Session, user: "models.User", project_id: Optiona
raise HTTPException(status_code=403, detail="You don't have access to this project")
+def effective_role(db: Session, user: "models.User", project_id: Optional[str]) -> str:
+ """The user's permissions role ON THIS PROJECT.
+
+ An app admin is admin everywhere. Otherwise a membership row may carry its own
+ role — so a PM on one job can be a plain Project User on another — and an empty
+ membership role falls back to the account's own role."""
+ if auth.is_admin(user):
+ return auth.ROLE_ADMIN
+ if project_id:
+ row = db.scalars(
+ select(models.ProjectMember).where(
+ (models.ProjectMember.user_id == user.id)
+ & (models.ProjectMember.project_id == project_id)
+ )
+ ).first()
+ if row and (row.role or "").strip():
+ return auth.normalize_role(row.role)
+ return auth.normalize_role(user.role)
+
+
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
what: str = "this action") -> None:
"""Destructive / baseline-changing operations: deleting a work package or a
project, and editing a SOP that has already been completed. Requires project
- access AND the project_admin (or admin) permissions role."""
+ access AND Project Admin *on that project*."""
require_project_access(db, user, project_id)
- if not auth.is_project_admin(user):
+ if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
raise HTTPException(
status_code=403,
- detail=f"{what} requires the Project Admin permissions role",
+ detail=f"{what} requires the Project Admin role on this project",
)
@@ -356,6 +376,9 @@ class RoleIn(BaseModel):
class ProjectAssignIn(BaseModel):
project_ids: list[str] = Field(default_factory=list)
+ # Optional per-project permissions role, {project_id: role}. Omit or use '' to
+ # inherit the account's own role on that project.
+ roles: dict[str, str] = Field(default_factory=dict)
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
@@ -711,11 +734,13 @@ def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_a
u = db.get(models.User, user_id)
if not u:
raise HTTPException(status_code=404, detail="User not found")
- assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
+ rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
return {
"user": u.to_dict(),
- "assigned": list(assigned),
+ "assigned": [r.project_id for r in rows],
+ # Per-project role overrides, keyed by project id ('' = inherit the account's).
+ "roles": {r.project_id: (r.role or "") for r in rows},
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
}
@@ -727,11 +752,19 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
if not u:
raise HTTPException(status_code=404, detail="User not found")
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
+ # Only the two project-scoped roles make sense here: app admin is global, and
+ # anything unrecognised falls back to inheriting the account's own role.
+ allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
+ roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
for pid in valid:
- db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
+ db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
+ role=roles.get(pid, "")))
+ log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username,
+ detail={"projects": len(valid),
+ "overrides": {p: r for p, r in roles.items() if p in valid}})
db.commit()
- return {"assigned": sorted(valid)}
+ return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
# ── Projects ─────────────────────────────────────────────────────────────────
@@ -1494,7 +1527,7 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
seen.add(u.id)
out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
"email": u.email, "project_role": u.project_role or "",
- "role": auth.normalize_role(u.role)})
+ "role": effective_role(db, u, project_id)})
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
return out
diff --git a/server/models.py b/server/models.py
index 6cc53f3..d1225d0 100644
--- a/server/models.py
+++ b/server/models.py
@@ -168,9 +168,13 @@ class User(Base):
class ProjectMember(Base):
- """Which users may access which projects. A user sees/operates on a project
- only if a row links them to it (admins bypass this entirely). One row per
- (user, project) pair."""
+ """Which users may access which projects, and what they may do there. A user
+ sees/operates on a project only if a row links them to it (admins bypass this
+ entirely). One row per (user, project) pair.
+
+ `role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
+ one job and a normal Project User on another. Empty means "inherit the account's
+ own role" (User.role), which is how every existing row behaves."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
@@ -181,6 +185,7 @@ class ProjectMember(Base):
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
+ role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)