Per-project access control + UI/feedback/admin refinements

Access control:
- project_members table; non-admins only see/operate on assigned
  projects (enforced across projects, SOPs, work packages — 403 else),
  admins bypass. Creating a project auto-grants its creator access.
- Admin API to get/set a user's project assignments, plus a checkbox
  assignment dialog in the Admin Console user list.

UI / workflow:
- Login page: drop the "Prime Controls" wordmark next to the logo.
- SOP tool: remove emoji icons from buttons and nav tabs.
- Rename "Step Comments" to "Feedback"; the author auto-populates
  (read-only) from the signed-in user.
- Move usage-log viewing to the Admin Console; add an admin card that
  lists all feedback/comments (who, what, page + step, when).
- Sample project name -> "Micron FMCS Install (sample)".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-29 16:38:56 -07:00
parent bdb798efdd
commit 5ad3ffa58e
8 changed files with 322 additions and 38 deletions

View File

@@ -103,6 +103,29 @@
<div id="users-create-msg" class="note"></div> <div id="users-create-msg" class="note"></div>
</div> </div>
<!-- ALL FEEDBACK / COMMENTS -->
<div class="card">
<h2>All feedback &amp; comments</h2>
<div class="sub" style="margin-bottom:10px">Every comment submitted across the suite — who wrote it, what they said, and where they were (page &amp; step) when they commented.</div>
<div class="row">
<button onclick="loadComments()">Refresh comments</button>
<select id="cmt-filter" onchange="renderComments()"><option value="">All sources</option></select>
<input id="cmt-search" placeholder="Search text / author…" oninput="renderComments()" style="flex:1;min-width:160px;padding:8px 10px;font:inherit;font-size:13px;border:1px solid var(--border-strong);border-radius:6px;">
</div>
<div id="comments-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- USAGE LOGS -->
<div class="card">
<h2>Usage logs</h2>
<div class="sub" style="margin-bottom:10px">Engagement recorded by the suite — sessions, step views, and actions. Note: stored locally per browser, so this reflects activity on <strong>this</strong> machine.</div>
<div class="row">
<button onclick="loadUsage()">Refresh</button>
<button onclick="downloadUsage()">Download JSON</button>
</div>
<div id="usage-admin" class="note" style="margin-top:12px">Click refresh to load.</div>
</div>
<!-- DB SNAPSHOT --> <!-- DB SNAPSHOT -->
<div class="card"> <div class="card">
<h2>Database snapshot</h2> <h2>Database snapshot</h2>

View File

@@ -11,6 +11,8 @@ function reveal(){
document.getElementById('admin-main').style.display=''; document.getElementById('admin-main').style.display='';
checkHealth(); checkHealth();
loadUsers(); loadUsers();
loadComments();
loadUsage();
} }
function showDenied(){ function showDenied(){
document.getElementById('admin-denied').style.display=''; document.getElementById('admin-denied').style.display='';
@@ -195,6 +197,7 @@ function renderUsers(list, meId){
'<td><span class="tag '+(active?'on':'off')+'">'+(active?'active':'disabled')+'</span></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 style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
'<td style="white-space:nowrap"><div class="row" style="gap:6px">'+ '<td style="white-space:nowrap"><div class="row" style="gap:6px">'+
'<button class="mini" onclick="manageProjects(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Projects</button>'+
'<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+ '<button class="mini" onclick="resetPw(\''+u.id+'\',\''+uesc(u.username).replace(/'/g,"\\'")+'\')">Reset password</button>'+
disableBtn+delBtn+ disableBtn+delBtn+
'</div></td>'+ '</div></td>'+
@@ -248,6 +251,125 @@ async function deleteUser(id, username){
else alert('Failed: '+((json && json.detail)||('HTTP '+status))); 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);
}
function closeProjectModal(){ const m=document.getElementById('proj-modal'); if(m) m.remove(); }
function openProjectModal(userId, username, projects, assigned, userObj){
closeProjectModal();
const isAdmin = userObj && userObj.role==='admin';
const items = projects.length ? projects.map(p =>
'<label style="display:flex;align-items:center;gap:8px;padding:7px 4px;border-bottom:1px solid var(--border);font-size:13px;cursor:pointer;">'+
'<input type="checkbox" value="'+uesc(p.id)+'"'+(assigned.has(p.id)?' checked':'')+(isAdmin?' disabled':'')+'>'+
'<span><strong>'+uesc(p.name||'(unnamed)')+'</strong>'+(p.number?' <span style="color:var(--muted)">'+uesc(p.number)+'</span>':'')+'</span>'+
'</label>').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:460px;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 — '+uesc(username)+'</div>'+
'<div style="padding:14px 18px;overflow:auto;">'+
(isAdmin ? '<div class="banner" style="margin:0 0 10px">This user is an <strong>admin</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.</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 { status } = await api('PUT','/api/auth/users/'+userId+'/projects',{project_ids:ids});
if(status===200) closeProjectModal();
else alert('Save failed (HTTP '+status+').');
};
}
// ── all feedback / comments ─────────────────────────────────────────────────────
let _comments = [];
async function loadComments(){
const box = document.getElementById('comments-admin');
box.textContent = 'Loading…';
const { status, json } = await api('GET','/api/comments');
if(status!==200 || !Array.isArray(json)){
box.innerHTML = '<div class="banner bad">Could not load comments (HTTP '+status+').</div>'; return;
}
_comments = json;
const sel = document.getElementById('cmt-filter'); const cur = sel.value;
const sources = [...new Set(json.map(c=>c.source).filter(Boolean))].sort();
sel.innerHTML = '<option value="">All sources</option>' + sources.map(s=>'<option value="'+uesc(s)+'">'+uesc(s)+'</option>').join('');
sel.value = cur;
renderComments();
}
function renderComments(){
const box = document.getElementById('comments-admin');
const src = document.getElementById('cmt-filter').value;
const q = (document.getElementById('cmt-search').value||'').toLowerCase();
let rows = _comments.filter(c => (!src || c.source===src) &&
(!q || ((c.text||'')+' '+(c.author||'')).toLowerCase().indexOf(q)>=0));
if(!rows.length){ box.innerHTML = '<div class="note">No comments'+((src||q)?' match the filter.':' yet.')+'</div>'; return; }
rows = rows.slice().sort((a,b)=> String(b.created_at||'').localeCompare(String(a.created_at||'')));
const fmt = s => s ? new Date(s).toLocaleString() : '—';
const where = c => {
const bits = [];
if(c.page) bits.push(uesc(c.page));
if(c.step!=null) bits.push('step '+c.step);
if(c.sop_id) bits.push('SOP '+uesc(c.sop_id));
if(c.wp_id) bits.push('WP '+uesc(c.wp_id));
return bits.join(' · ') || '—';
};
box.innerHTML = '<table class="users"><thead><tr><th>When</th><th>Who</th><th>Source</th><th>Where</th><th>Comment</th></tr></thead><tbody>'+
rows.map(c => '<tr>'+
'<td style="white-space:nowrap;color:var(--muted)">'+fmt(c.created_at)+'</td>'+
'<td><strong>'+uesc(c.author||'Anonymous')+'</strong></td>'+
'<td>'+uesc(c.source||'—')+'</td>'+
'<td style="color:var(--muted)">'+where(c)+'</td>'+
'<td>'+uesc(c.text||'')+'</td>'+
'</tr>').join('')+'</tbody></table>';
}
// ── usage logs (read from this browser's localStorage) ──────────────────────────
const USAGE_KEY = 'wp_suite_analytics_v1';
function usageLoad(){ try { return JSON.parse(localStorage.getItem(USAGE_KEY)) || {events:[]}; } catch(e){ return {events:[]}; } }
function loadUsage(){
const box = document.getElementById('usage-admin');
const evs = (usageLoad().events) || [];
if(!evs.length){ box.innerHTML = '<div class="note">No usage recorded in this browser yet.</div>'; return; }
const byEvent = {}, byStep = {}, sessions = new Set();
let first = evs[0].ts, last = evs[0].ts;
evs.forEach(e => {
byEvent[e.event] = (byEvent[e.event]||0)+1;
if(e.session) sessions.add(e.session);
if(e.event==='step_view' && e.detail) byStep[e.detail.step] = (byStep[e.detail.step]||0)+1;
if(e.ts < first) first = e.ts; if(e.ts > last) last = e.ts;
});
const fmt = s => s ? new Date(s).toLocaleString() : '—';
let html = '<table class="kv">'+
'<tr><th>Sessions</th><td>'+sessions.size+'</td></tr>'+
'<tr><th>Events</th><td>'+evs.length+'</td></tr>'+
'<tr><th>Range</th><td style="font-weight:600">'+fmt(first)+' → '+fmt(last)+'</td></tr></table>';
html += '<h2 style="margin-top:16px">Step views</h2><table class="users"><thead><tr><th>Step</th><th>Views</th></tr></thead><tbody>';
for(let i=1;i<=10;i++) html += '<tr><td>Step '+i+'</td><td>'+(byStep[i]||0)+'</td></tr>';
html += '</tbody></table>';
html += '<h2 style="margin-top:16px">Actions</h2><table class="users"><thead><tr><th>Event</th><th>Count</th></tr></thead><tbody>';
Object.keys(byEvent).sort().forEach(k => html += '<tr><td>'+uesc(k)+'</td><td>'+byEvent[k]+'</td></tr>');
html += '</tbody></table>';
box.innerHTML = html;
}
function downloadUsage(){
const blob = new Blob([JSON.stringify(usageLoad(),null,2)], {type:'application/json'});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob);
a.download = 'wp-suite-usage-' + new Date().toISOString().slice(0,10) + '.json';
a.click(); setTimeout(()=>URL.revokeObjectURL(a.href), 1000);
}
// ── access control: admins only ───────────────────────────────────────────────── // ── access control: admins only ─────────────────────────────────────────────────
// auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready'). // auth-guard.js requires a login and sets window.WP_USER (firing 'wp-auth-ready').
// Show the console for admins; otherwise show the "Admins only" notice. // Show the console for admins; otherwise show the "Admins only" notice.

View File

@@ -76,7 +76,6 @@
<main class="card"> <main class="card">
<div class="brand"> <div class="brand">
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'"> <img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
<span class="name">Prime Controls</span>
</div> </div>
<h1>Sign in</h1> <h1>Sign in</h1>
<p class="sub">Work Package Suite</p> <p class="sub">Work Package Suite</p>

View File

@@ -25,7 +25,7 @@
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); } function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
var SAMPLE_PROJECT = { var SAMPLE_PROJECT = {
name: 'Micron — INC Construction Work Packages', number: '26-67-008', name: 'Micron FMCS Install (sample)', number: '26-67-008',
client: 'Micron Technology, Inc.', division: 'Semiconductor', client: 'Micron Technology, Inc.', division: 'Semiconductor',
site: 'Boise, ID — Fab', sample: true site: 'Boise, ID — Fab', sample: true
}; };

View File

@@ -159,6 +159,12 @@ window.addEventListener('DOMContentLoaded',()=>{
else if(tab === 'wp' || tab === 'sop') switchTool(tab); else if(tab === 'wp' || tab === 'sop') switchTool(tab);
track('app_open'); track('app_open');
// Feedback author auto-populates from the signed-in user (auth-guard sets
// window.WP_USER and fires 'wp-auth-ready'); the field is read-only.
setCommenterName();
document.addEventListener('wp-auth-ready', setCommenterName);
let _fieldTimer; let _fieldTimer;
document.addEventListener('input', e=>{ document.addEventListener('input', e=>{
const t = e.target; const t = e.target;
@@ -853,8 +859,17 @@ function toggleComments(){
if(panel.style.display === 'block') loadStepComments(); if(panel.style.display === 'block') loadStepComments();
} }
function currentUserName(){
const u = window.WP_USER;
return (u && (u.full_name || u.username)) || '';
}
function setCommenterName(){
const el = document.getElementById('commenter-name');
if(el) el.value = currentUserName();
}
function submitComment(){ function submitComment(){
const name = document.getElementById('commenter-name').value || 'Anonymous'; const name = document.getElementById('commenter-name').value || currentUserName() || 'Anonymous';
const text = document.getElementById('comment-text').value.trim(); const text = document.getElementById('comment-text').value.trim();
if(!text){ alert('Please enter a comment.'); return; } if(!text){ alert('Please enter a comment.'); return; }

View File

@@ -23,10 +23,9 @@
</div> </div>
</div> </div>
<div class="header-right"> <div class="header-right">
<button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button> <button id="load-sample-btn" class="header-button" onclick="loadSampleData()" title="Load sample data for the current tool (SOP or Work Package)">Load Sample</button>
<button class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</button> <button class="header-button" onclick="toggleComments()" title="Leave feedback for the current step">Feedback</button>
<button class="header-button" onclick="showAnalytics()" title="Review usage logs for this tool">📊 Usage Logs</button> <button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">Help</button>
<button class="header-button" onclick="openHelp()" title="How the suite works + key concepts">❔ Help</button>
<span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span> <span class="step-counter"><span id="current-step">1</span> / <span id="total-steps">10</span></span>
</div> </div>
</div> </div>
@@ -34,13 +33,13 @@
<!-- MAIN NAVIGATION --> <!-- MAIN NAVIGATION -->
<div class="main-nav"> <div class="main-nav">
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')"> <button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
<span class="tab-icon">⚙️</span> SOP Configuration SOP Configuration
</button> </button>
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')"> <button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
<span class="tab-icon">📋</span> Work Package Creation Work Package Creation
</button> </button>
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')"> <button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
<span class="tab-icon">📊</span> Dashboard Dashboard
</button> </button>
</div> </div>
@@ -298,7 +297,7 @@
<div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;"> <div style="display:flex; gap:0.5rem; margin-top:1rem; flex-wrap:wrap;">
<input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;"> <input type="text" id="seq-add-input" placeholder="New step name" onkeydown="if(event.key==='Enter'){addSequenceStep();}" style="flex:1; min-width:200px; padding:0.5rem; border:1px solid var(--border); border-radius:4px;">
<button class="add-btn" onclick="addSequenceStep()">+ Add Step</button> <button class="add-btn" onclick="addSequenceStep()">+ Add Step</button>
<button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button> <button class="add-btn" onclick="addSequenceGate()" style="background:var(--warning);">Add QC Hold</button>
</div> </div>
</div> </div>
@@ -329,9 +328,9 @@
<!-- SOP NAVIGATION --> <!-- SOP NAVIGATION -->
<div class="step-navigation"> <div class="step-navigation">
<button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button> <button class="nav-btn" id="sop-prev-btn" onclick="previousStep()">Back</button>
<button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button> <button class="nav-btn" id="sop-next-btn" onclick="nextStep()">Next</button>
<button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button> <button class="nav-btn primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
</div> </div>
</div> </div>
@@ -342,9 +341,9 @@
<div id="tool-wp" class="tool"> <div id="tool-wp" class="tool">
<!-- Shown until the SOP is complete --> <!-- Shown until the SOP is complete -->
<div id="wp-gate" style="padding: 3rem 2rem; text-align: center;"> <div id="wp-gate" style="padding: 3rem 2rem; text-align: center;">
<h2>📋 Work Package Creation</h2> <h2>Work Package Creation</h2>
<p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p> <p style="color: var(--text-light); margin: 1rem 0;">Complete the SOP Configuration first to enable Work Package creation. Once the SOP is finished, the full creator loads here with your project defaults pre-populated.</p>
<button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button> <button class="nav-btn primary" onclick="switchTool('sop')" style="margin-top: 1rem;">Go to SOP Configuration</button>
</div> </div>
<!-- The real Work Package Creator, embedded once the SOP is complete --> <!-- The real Work Package Creator, embedded once the SOP is complete -->
<iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe> <iframe id="wp-frame" title="Work Package Creator" style="display:none; width:100%; border:0; min-height: calc(100vh - 200px);"></iframe>
@@ -357,12 +356,12 @@
<!-- STEP COMMENTS DROPDOWN (toggled from header) --> <!-- STEP COMMENTS DROPDOWN (toggled from header) -->
<div id="comments-panel" class="comments-dropdown" style="display: none;"> <div id="comments-panel" class="comments-dropdown" style="display: none;">
<div class="comments-dropdown-header"> <div class="comments-dropdown-header">
<strong>💬 Step Comments</strong> <strong>Feedback</strong>
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close"></button> <button onclick="toggleComments()" class="comments-dropdown-close" title="Close"></button>
</div> </div>
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</label> <label style="font-weight: 600; font-size: 13px;">Your Name</label>
<input type="text" id="commenter-name" placeholder="e.g., your name" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem;"> <input type="text" id="commenter-name" placeholder="(signed-in user)" readonly title="Taken from your sign-in" style="width: 100%; padding: 0.5rem; border: 1px solid var(--border); border-radius: 4px; margin-top: 0.25rem; background: var(--bg);">
</div> </div>
<div style="margin-bottom: 1rem;"> <div style="margin-bottom: 1rem;">
<label style="font-weight: 600; font-size: 13px;">Feedback</label> <label style="font-weight: 600; font-size: 13px;">Feedback</label>
@@ -370,8 +369,8 @@
</div> </div>
<div style="display:flex; gap:0.5rem; flex-wrap:wrap;"> <div style="display:flex; gap:0.5rem; flex-wrap:wrap;">
<button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button> <button onclick="submitComment()" style="background: var(--primary); color: white; padding: 0.5rem 1rem; border: none; border-radius: 4px; cursor: pointer; font-weight: 600;">Submit</button>
<button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button> <button onclick="exportComments()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Export</button>
<button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button> <button onclick="document.getElementById('sop-comments-import').click()" style="background: var(--bg); color: var(--text); border: 1px solid var(--border); padding: 0.5rem 1rem; border-radius: 4px; cursor: pointer; font-weight: 600;">Import</button>
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)"> <input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
</div> </div>
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div> <div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>

View File

@@ -56,6 +56,54 @@ def gen_id(prefix: str) -> str:
return f"{prefix}_{uuid.uuid4().hex[:12]}" return f"{prefix}_{uuid.uuid4().hex[:12]}"
# ── Per-project access control ─────────────────────────────────────────────────
# A non-admin user may only touch projects they're a member of (project_members).
# Admins bypass all of this. Resources with no project_id (legacy/orphan) are not
# gated. List endpoints are scoped to accessible projects; single-resource and
# mutating endpoints raise 403 on no access.
def accessible_project_ids(db: Session, user: "models.User"):
"""Return the set of project ids the user may access, or None for 'all' (admin)."""
if user.role == "admin":
return None
rows = db.scalars(
select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user.id)
).all()
return set(rows)
def require_project_access(db: Session, user: "models.User", project_id: Optional[str]) -> None:
if user.role == "admin" or project_id is None:
return
ok = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user.id)
& (models.ProjectMember.project_id == project_id)
)
)
if not ok:
raise HTTPException(status_code=403, detail="You don't have access to this project")
def scope_to_access(stmt, column, db: Session, user: "models.User"):
"""Restrict a SELECT to the user's accessible projects (no-op for admins)."""
ids = accessible_project_ids(db, user)
if ids is None:
return stmt
return stmt.where(column.in_(ids))
def grant_project_access(db: Session, user_id: str, project_id: str) -> None:
"""Add a (user, project) membership if it isn't already there."""
exists = db.scalar(
select(models.ProjectMember.id).where(
(models.ProjectMember.user_id == user_id)
& (models.ProjectMember.project_id == project_id)
)
)
if not exists:
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=project_id))
# ── Request bodies ─────────────────────────────────────────────────────────── # ── Request bodies ───────────────────────────────────────────────────────────
class ProjectIn(BaseModel): class ProjectIn(BaseModel):
id: Optional[str] = None id: Optional[str] = None
@@ -143,6 +191,10 @@ class ActiveIn(BaseModel):
is_active: bool is_active: bool
class ProjectAssignIn(BaseModel):
project_ids: list[str] = Field(default_factory=list)
@app.post("/api/auth/login") @app.post("/api/auth/login")
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)): def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
"""Verify credentials and, on success, set the HttpOnly session cookie.""" """Verify credentials and, on success, set the HttpOnly session cookie."""
@@ -249,10 +301,43 @@ def delete_user(user_id: str, admin: models.User = Depends(auth.require_admin),
return {"deleted": user_id} return {"deleted": user_id}
@app.get("/api/auth/users/{user_id}/projects")
def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Which projects a user is assigned to, plus the full project list for the
assignment UI. (Admins implicitly access every project regardless.)"""
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()
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
return {
"user": u.to_dict(),
"assigned": list(assigned),
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
}
@app.put("/api/auth/users/{user_id}/projects")
def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User = Depends(auth.require_admin), db: Session = Depends(get_db)):
"""Replace a user's project assignments with the given set."""
u = db.get(models.User, user_id)
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()
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.commit()
return {"assigned": sorted(valid)}
# ── Projects ───────────────────────────────────────────────────────────────── # ── Projects ─────────────────────────────────────────────────────────────────
@app.post("/api/projects") @app.post("/api/projects")
def upsert_project(body: ProjectIn, db: Session = Depends(get_db)): def upsert_project(body: ProjectIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, body.id) if body.id else None proj = db.get(models.Project, body.id) if body.id else None
is_new = proj is None
if not is_new:
require_project_access(db, user, proj.id)
if proj is None: if proj is None:
proj = models.Project(id=body.id or gen_id("proj")) proj = models.Project(id=body.id or gen_id("proj"))
db.add(proj) db.add(proj)
@@ -265,29 +350,36 @@ def upsert_project(body: ProjectIn, db: Session = Depends(get_db)):
proj.created_by = body.created_by or proj.created_by proj.created_by = body.created_by or proj.created_by
proj.data = body.data proj.data = body.data
db.commit() db.commit()
# A project created by a non-admin auto-grants its creator access.
if is_new and user.role != "admin":
grant_project_access(db, user.id, proj.id)
db.commit()
db.refresh(proj) db.refresh(proj)
return proj.to_dict() return proj.to_dict()
@app.get("/api/projects") @app.get("/api/projects")
def list_projects(db: Session = Depends(get_db)): def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all() stmt = scope_to_access(select(models.Project), models.Project.id, db, user).order_by(models.Project.updated_at.desc())
rows = db.scalars(stmt).all()
return [p.summary() for p in rows] return [p.summary() for p in rows]
@app.get("/api/projects/{project_id}") @app.get("/api/projects/{project_id}")
def get_project(project_id: str, db: Session = Depends(get_db)): def get_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id) proj = db.get(models.Project, project_id)
if not proj: if not proj:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id)
return proj.to_dict() return proj.to_dict()
@app.delete("/api/projects/{project_id}") @app.delete("/api/projects/{project_id}")
def delete_project(project_id: str, db: Session = Depends(get_db)): def delete_project(project_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
proj = db.get(models.Project, project_id) proj = db.get(models.Project, project_id)
if not proj: if not proj:
raise HTTPException(status_code=404, detail="Project not found") raise HTTPException(status_code=404, detail="Project not found")
require_project_access(db, user, proj.id)
db.delete(proj) db.delete(proj)
db.commit() db.commit()
return {"deleted": project_id} return {"deleted": project_id}
@@ -295,8 +387,11 @@ def delete_project(project_id: str, db: Session = Depends(get_db)):
# ── SOPs ───────────────────────────────────────────────────────────────────── # ── SOPs ─────────────────────────────────────────────────────────────────────
@app.post("/api/sops") @app.post("/api/sops")
def upsert_sop(body: SopIn, db: Session = Depends(get_db)): def upsert_sop(body: SopIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, body.project_id)
sop = db.get(models.Sop, body.id) if body.id else None sop = db.get(models.Sop, body.id) if body.id else None
if sop is not None:
require_project_access(db, user, sop.project_id)
if sop is None: if sop is None:
sop = models.Sop(id=body.id or gen_id("sop")) sop = models.Sop(id=body.id or gen_id("sop"))
db.add(sop) db.add(sop)
@@ -312,21 +407,23 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
@app.get("/api/sops") @app.get("/api/sops")
def list_sops(project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): def list_sops(project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Sop) stmt = select(models.Sop)
if project_id: if project_id:
stmt = stmt.where(models.Sop.project_id == project_id) stmt = stmt.where(models.Sop.project_id == project_id)
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all() rows = db.scalars(stmt.order_by(models.Sop.updated_at.desc())).all()
return [s.summary() for s in rows] return [s.summary() for s in rows]
@app.get("/api/sops/latest") @app.get("/api/sops/latest")
def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), db: Session = Depends(get_db)): def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
stmt = select(models.Sop) stmt = select(models.Sop)
if complete is not None: if complete is not None:
stmt = stmt.where(models.Sop.complete == complete) stmt = stmt.where(models.Sop.complete == complete)
if project_id: if project_id:
stmt = stmt.where(models.Sop.project_id == project_id) stmt = stmt.where(models.Sop.project_id == project_id)
stmt = scope_to_access(stmt, models.Sop.project_id, db, user)
sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first() sop = db.scalars(stmt.order_by(models.Sop.updated_at.desc()).limit(1)).first()
if not sop: if not sop:
raise HTTPException(status_code=404, detail="No SOP found") raise HTTPException(status_code=404, detail="No SOP found")
@@ -334,18 +431,20 @@ def latest_sop(complete: Optional[bool] = None, project_id: Optional[str] = Quer
@app.get("/api/sops/{sop_id}") @app.get("/api/sops/{sop_id}")
def get_sop(sop_id: str, db: Session = Depends(get_db)): def get_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id) sop = db.get(models.Sop, sop_id)
if not sop: if not sop:
raise HTTPException(status_code=404, detail="SOP not found") raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
return sop.to_dict() return sop.to_dict()
@app.delete("/api/sops/{sop_id}") @app.delete("/api/sops/{sop_id}")
def delete_sop(sop_id: str, db: Session = Depends(get_db)): def delete_sop(sop_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
sop = db.get(models.Sop, sop_id) sop = db.get(models.Sop, sop_id)
if not sop: if not sop:
raise HTTPException(status_code=404, detail="SOP not found") raise HTTPException(status_code=404, detail="SOP not found")
require_project_access(db, user, sop.project_id)
db.delete(sop) db.delete(sop)
db.commit() db.commit()
return {"deleted": sop_id} return {"deleted": sop_id}
@@ -353,8 +452,11 @@ def delete_sop(sop_id: str, db: Session = Depends(get_db)):
# ── Work Packages ──────────────────────────────────────────────────────────── # ── Work Packages ────────────────────────────────────────────────────────────
@app.post("/api/wps") @app.post("/api/wps")
def upsert_wp(body: WpIn, db: Session = Depends(get_db)): def upsert_wp(body: WpIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
require_project_access(db, user, body.project_id)
wp = db.get(models.WorkPackage, body.id) if body.id else None wp = db.get(models.WorkPackage, body.id) if body.id else None
if wp is not None:
require_project_access(db, user, wp.project_id)
if wp is None: if wp is None:
wp = models.WorkPackage(id=body.id or gen_id("wp")) wp = models.WorkPackage(id=body.id or gen_id("wp"))
db.add(wp) db.add(wp)
@@ -378,6 +480,7 @@ def list_wps(
sop_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None),
parent_id: Optional[str] = Query(None), parent_id: Optional[str] = Query(None),
status: Optional[str] = Query(None), status: Optional[str] = Query(None),
user: models.User = Depends(auth.get_current_user),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
stmt = select(models.WorkPackage) stmt = select(models.WorkPackage)
@@ -389,12 +492,13 @@ def list_wps(
stmt = stmt.where(models.WorkPackage.parent_id == parent_id) stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
if status: if status:
stmt = stmt.where(models.WorkPackage.status == status) stmt = stmt.where(models.WorkPackage.status == status)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all() rows = db.scalars(stmt.order_by(models.WorkPackage.updated_at.desc())).all()
return [w.summary() for w in rows] return [w.summary() for w in rows]
@app.get("/api/wps/metrics") @app.get("/api/wps/metrics")
def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), db: Session = Depends(get_db)): def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] = Query(None), user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Aggregates for the dashboard. Masters (data.split == true) are excluded """Aggregates for the dashboard. Masters (data.split == true) are excluded
from counts so a split package's hours aren't double-counted with its from counts so a split package's hours aren't double-counted with its
instances.""" instances."""
@@ -403,6 +507,7 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
stmt = stmt.where(models.WorkPackage.project_id == project_id) stmt = stmt.where(models.WorkPackage.project_id == project_id)
if sop_id: if sop_id:
stmt = stmt.where(models.WorkPackage.sop_id == sop_id) stmt = stmt.where(models.WorkPackage.sop_id == sop_id)
stmt = scope_to_access(stmt, models.WorkPackage.project_id, db, user)
rows = db.scalars(stmt).all() rows = db.scalars(stmt).all()
by_status: dict[str, int] = {} by_status: dict[str, int] = {}
@@ -436,30 +541,33 @@ def wp_metrics(project_id: Optional[str] = Query(None), sop_id: Optional[str] =
@app.get("/api/wps/{wp_id}") @app.get("/api/wps/{wp_id}")
def get_wp(wp_id: str, db: Session = Depends(get_db)): def get_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
return wp.to_dict() return wp.to_dict()
@app.delete("/api/wps/{wp_id}") @app.delete("/api/wps/{wp_id}")
def delete_wp(wp_id: str, db: Session = Depends(get_db)): def delete_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
db.delete(wp) db.delete(wp)
db.commit() db.commit()
return {"deleted": wp_id} return {"deleted": wp_id}
@app.post("/api/wps/{wp_id}/issue") @app.post("/api/wps/{wp_id}/issue")
def issue_wp(wp_id: str, db: Session = Depends(get_db)): def issue_wp(wp_id: str, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
"""Release a Work Package to the field. Refuses if any constraint is still """Release a Work Package to the field. Refuses if any constraint is still
open (the AWP release gate).""" open (the AWP release gate)."""
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
constraints = (wp.data or {}).get("constraints") or [] constraints = (wp.data or {}).get("constraints") or []
open_names = [c.get("name") for c in constraints if c.get("status") == "open"] open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
if open_names: if open_names:
@@ -472,10 +580,11 @@ def issue_wp(wp_id: str, db: Session = Depends(get_db)):
@app.post("/api/wps/{wp_id}/status") @app.post("/api/wps/{wp_id}/status")
def set_wp_status(wp_id: str, body: StatusIn, db: Session = Depends(get_db)): def set_wp_status(wp_id: str, body: StatusIn, user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
wp = db.get(models.WorkPackage, wp_id) wp = db.get(models.WorkPackage, wp_id)
if not wp: if not wp:
raise HTTPException(status_code=404, detail="Work Package not found") raise HTTPException(status_code=404, detail="Work Package not found")
require_project_access(db, user, wp.project_id)
wp.status = body.status wp.status = body.status
if body.status == "Issued" and wp.issued_at is None: if body.status == "Issued" and wp.issued_at is None:
wp.issued_at = models.utcnow() wp.issued_at = models.utcnow()

View File

@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
""" """
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Optional from typing import Optional
from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON from sqlalchemy import String, Boolean, Integer, DateTime, ForeignKey, Text, JSON, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column from sqlalchemy.orm import Mapped, mapped_column
from .db import Base from .db import Base
@@ -137,6 +137,23 @@ 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."""
__tablename__ = "project_members"
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
id: Mapped[str] = mapped_column(String(40), primary_key=True)
user_id: Mapped[str] = mapped_column(
String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True
)
project_id: Mapped[str] = mapped_column(
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
)
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
class Comment(Base): class Comment(Base):
__tablename__ = "comments" __tablename__ = "comments"