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='
No users yet.
'; return; } @@ -240,10 +279,10 @@ function renderUsers(list, meId){ ''+uesc(u.email||'')+''+ ''+roleCell+''+ ''+projRoleCell+''+ + ''+projAccessCell(u)+''+ ''+(active?'active':'disabled')+''+ ''+fmt(u.last_login_at)+''+ '
'+ - ''+ ''+ disableBtn+delBtn+ '
'+ @@ -253,6 +292,7 @@ function renderUsers(list, meId){ 'UsernameNameEmail'+ 'Permissions'+ 'Project role'+ + 'Project access'+ 'StatusLast loginActions'+ ''+rows+''+ '
Permissions — '+ @@ -333,25 +373,45 @@ async function deleteUser(id, username){ 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); + 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){ +function openProjectModal(userId, username, projects, assigned, userObj, roles){ closeProjectModal(); - const isAdmin = userObj && userObj.role==='admin'; - const items = projects.length ? projects.map(p => - '').join('') : '
No projects exist yet.
'; + 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. + const items = projects.length ? projects.map(p => { + const on = assigned.has(p.id); + const cur = roles[p.id] || ''; + const sel = ''; + return '
'+ + ''+ sel + + '
'; + }).join('') : '
No projects exist yet.
'; 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 = - '
'+ - '
Project access — '+uesc(username)+'
'+ + '
'+ + '
Project access & permissions — '+uesc(username)+'
'+ '
'+ - (isAdmin ? '' : '
Tick the projects this user may access.
')+ + (isAdmin ? '' + : '
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 ; if a saved value isn't one of the presets // (e.g. legacy free text), add it as an option so the round-trip preserves it. @@ -472,11 +479,33 @@ function switchTool(tool){ document.getElementById('total-steps').textContent = (tool === 'sop') ? '10' : '—'; if(contentTool === 'wp') renderWPTab(isDash); + applyEmbedLayout(contentTool === 'wp'); updateStepUI(); updateProjectDisplay(); } +// The embedded creator/dashboard fills the window below the app chrome, so there +// is ONE scrollbar (the iframe's) instead of a skinny inner pane inside a scrolling +// page — and the creator's sticky bars have a real viewport to stick to. +function applyEmbedLayout(on){ + const area = document.querySelector('.content-area'); + const frame = document.getElementById('wp-frame'); + if(area) area.classList.toggle('embed-full', !!on); + if(frame) frame.classList.toggle('fill', !!on); + document.body.classList.toggle('embed-full', !!on); + if(on) measureChrome(); +} + +// Height of the app bar + tab strip, so the iframe can be exactly the rest. +function measureChrome(){ + const hdr = document.querySelector('.header'); + const nav = document.querySelector('.main-nav'); + const h = (hdr ? hdr.offsetHeight : 48) + (nav ? nav.offsetHeight : 48); + document.documentElement.style.setProperty('--wp-chrome-h', h + 'px'); +} +window.addEventListener('resize', () => { if(document.body.classList.contains('embed-full')) measureChrome(); }, {passive:true}); + // Show the gate or the embedded Work Package Creator depending on SOP status. // wantDash=true opens the creator straight to the dashboard view. function renderWPTab(wantDash){ @@ -623,6 +652,7 @@ async function loadProjectUsers(){ projectUsersLoaded = true; renderTeamPickers(); renderTeamMembers(); + renderSignoffRolePickers(); } // One of project people, reused everywhere the SOP names someone. Keeps a +// name that has no matching account as a selected "(no account)" option so older +// SOPs — and the sample's fictional names — are never silently dropped. +function userSelectOptions(curId, curName){ + let html = '' + + projectUsers.map(u => ``).join(''); + if(curName && !userById(curId)){ + html += ``; + } + return html; +} + +// Sign-off roles (step 3) name the people who must sign a package, so they use the +// same picker as the leadership slots — a signature belongs to an account. +function renderSignoffRolePickers(){ + [['role_super_name', 0], ['role_foreman_name', 1]].forEach(([id, ix]) => { + const sel = document.getElementById(id); + const r = state.signoffRoles[ix]; + if(!sel || !r) return; + sel.innerHTML = userSelectOptions(r.userId || '', r.name || ''); + sel.onchange = function(){ + if(this.value === '__orphan__') return; + const u = userById(this.value); + r.userId = u ? u.id : ''; + r.name = u ? (u.full_name || u.username) : ''; + renderSignoffRolePickers(); + }; + }); + renderOptionalRoles(); +} + +// Read the four leadership pickers back into state. `state.team[key]` always holds +// a display NAME and `state.teamIds[key]` the account id; a name kept from an older +// SOP whose person has no account (the "(no account)" option) is left alone. +function syncTeamFromPickers(){ + if(!state.teamIds) state.teamIds = {pm:'', apm:'', cm:'', qm:''}; + ['pm','apm','cm','qm'].forEach(key => { + const sel = document.getElementById('proj_' + key); + if(!sel) return; + if(sel.value === '__orphan__') return; // legacy typed name — keep it + const u = userById(sel.value); + state.teamIds[key] = u ? u.id : ''; + if(u) state.team[key] = u.full_name || u.username; + else if(sel.value === '') state.team[key] = ''; // explicitly unassigned + }); +} + +function setOptionalRolePerson(ix, value){ + const r = state.signoffRoles[ix]; + if(!r || value === '__orphan__') return; + const u = userById(value); + r.userId = u ? u.id : ''; + r.name = u ? (u.full_name || u.username) : ''; + renderOptionalRoles(); +} + function setTeamLead(key, userId){ if(userId === '__orphan__') return; // re-selecting the legacy name changes nothing const u = userById(userId); @@ -715,7 +801,7 @@ function renderOptionalRoles(){ - +
`).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)