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:
@@ -103,6 +103,29 @@
|
||||
<div id="users-create-msg" class="note"></div>
|
||||
</div>
|
||||
|
||||
<!-- ALL FEEDBACK / COMMENTS -->
|
||||
<div class="card">
|
||||
<h2>All feedback & 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 & 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 -->
|
||||
<div class="card">
|
||||
<h2>Database snapshot</h2>
|
||||
|
||||
122
html/admin.js
122
html/admin.js
@@ -11,6 +11,8 @@ function reveal(){
|
||||
document.getElementById('admin-main').style.display='';
|
||||
checkHealth();
|
||||
loadUsers();
|
||||
loadComments();
|
||||
loadUsage();
|
||||
}
|
||||
function showDenied(){
|
||||
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 style="white-space:nowrap;color:var(--muted)">'+fmt(u.last_login_at)+'</td>'+
|
||||
'<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>'+
|
||||
disableBtn+delBtn+
|
||||
'</div></td>'+
|
||||
@@ -248,6 +251,125 @@ async function deleteUser(id, username){
|
||||
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 ─────────────────────────────────────────────────
|
||||
// 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.
|
||||
|
||||
@@ -76,7 +76,6 @@
|
||||
<main class="card">
|
||||
<div class="brand">
|
||||
<img src="prime-controls-logo.jpg" alt="Prime Controls" onerror="this.style.display='none'">
|
||||
<span class="name">Prime Controls</span>
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="sub">Work Package Suite</p>
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
function cacheRemove(id) { writeLocal(readLocal().filter(function (x) { return x.id !== id; })); }
|
||||
|
||||
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',
|
||||
site: 'Boise, ID — Fab', sample: true
|
||||
};
|
||||
|
||||
@@ -159,6 +159,12 @@ window.addEventListener('DOMContentLoaded',()=>{
|
||||
else if(tab === 'wp' || tab === 'sop') switchTool(tab);
|
||||
|
||||
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;
|
||||
document.addEventListener('input', e=>{
|
||||
const t = e.target;
|
||||
@@ -853,8 +859,17 @@ function toggleComments(){
|
||||
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(){
|
||||
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();
|
||||
|
||||
if(!text){ alert('Please enter a comment.'); return; }
|
||||
|
||||
@@ -23,10 +23,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 class="header-button" onclick="toggleComments()" title="View and add comments for the current step">💬 Step Comments</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 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="Leave feedback for the current step">Feedback</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,13 +33,13 @@
|
||||
<!-- MAIN NAVIGATION -->
|
||||
<div class="main-nav">
|
||||
<button class="nav-tab active" data-tab="sop" onclick="switchTool('sop')">
|
||||
<span class="tab-icon">⚙️</span> SOP Configuration
|
||||
SOP Configuration
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="wp" onclick="switchTool('wp')">
|
||||
<span class="tab-icon">📋</span> Work Package Creation
|
||||
Work Package Creation
|
||||
</button>
|
||||
<button class="nav-tab" data-tab="dashboard" onclick="switchTool('dashboard')">
|
||||
<span class="tab-icon">📊</span> Dashboard
|
||||
Dashboard
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -298,7 +297,7 @@
|
||||
<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;">
|
||||
<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>
|
||||
|
||||
@@ -329,9 +328,9 @@
|
||||
|
||||
<!-- SOP NAVIGATION -->
|
||||
<div class="step-navigation">
|
||||
<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 primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">✓ SOP Complete</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 primary" id="sop-complete-btn" onclick="completeSOP()" style="display: none;">SOP Complete</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -342,9 +341,9 @@
|
||||
<div id="tool-wp" class="tool">
|
||||
<!-- Shown until the SOP is complete -->
|
||||
<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>
|
||||
<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>
|
||||
<!-- 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>
|
||||
@@ -357,12 +356,12 @@
|
||||
<!-- STEP COMMENTS DROPDOWN (toggled from header) -->
|
||||
<div id="comments-panel" class="comments-dropdown" style="display: none;">
|
||||
<div class="comments-dropdown-header">
|
||||
<strong>💬 Step Comments</strong>
|
||||
<strong>Feedback</strong>
|
||||
<button onclick="toggleComments()" class="comments-dropdown-close" title="Close">✕</button>
|
||||
</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name (optional)</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;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Your Name</label>
|
||||
<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 style="margin-bottom: 1rem;">
|
||||
<label style="font-weight: 600; font-size: 13px;">Feedback</label>
|
||||
@@ -370,8 +369,8 @@
|
||||
</div>
|
||||
<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="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="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>
|
||||
<input type="file" id="sop-comments-import" accept="application/json" style="display:none" onchange="importComments(event)">
|
||||
</div>
|
||||
<div id="comments-list" style="margin-top: 1rem; max-height: 240px; overflow-y: auto;"></div>
|
||||
|
||||
141
server/app.py
141
server/app.py
@@ -56,6 +56,54 @@ def gen_id(prefix: str) -> str:
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
class ProjectIn(BaseModel):
|
||||
id: Optional[str] = None
|
||||
@@ -143,6 +191,10 @@ class ActiveIn(BaseModel):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class ProjectAssignIn(BaseModel):
|
||||
project_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
||||
@app.post("/api/auth/login")
|
||||
def login(body: LoginIn, request: Request, response: Response, db: Session = Depends(get_db)):
|
||||
"""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}
|
||||
|
||||
|
||||
@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 ─────────────────────────────────────────────────────────────────
|
||||
@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
|
||||
is_new = proj is None
|
||||
if not is_new:
|
||||
require_project_access(db, user, proj.id)
|
||||
if proj is None:
|
||||
proj = models.Project(id=body.id or gen_id("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.data = body.data
|
||||
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)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@app.get("/api/projects")
|
||||
def list_projects(db: Session = Depends(get_db)):
|
||||
rows = db.scalars(select(models.Project).order_by(models.Project.updated_at.desc())).all()
|
||||
def list_projects(user: models.User = Depends(auth.get_current_user), db: Session = Depends(get_db)):
|
||||
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]
|
||||
|
||||
|
||||
@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)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, proj.id)
|
||||
return proj.to_dict()
|
||||
|
||||
|
||||
@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)
|
||||
if not proj:
|
||||
raise HTTPException(status_code=404, detail="Project not found")
|
||||
require_project_access(db, user, proj.id)
|
||||
db.delete(proj)
|
||||
db.commit()
|
||||
return {"deleted": project_id}
|
||||
@@ -295,8 +387,11 @@ def delete_project(project_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
# ── 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
|
||||
if sop is not None:
|
||||
require_project_access(db, user, sop.project_id)
|
||||
if sop is None:
|
||||
sop = models.Sop(id=body.id or gen_id("sop"))
|
||||
db.add(sop)
|
||||
@@ -312,21 +407,23 @@ def upsert_sop(body: SopIn, db: Session = Depends(get_db)):
|
||||
|
||||
|
||||
@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)
|
||||
if 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()
|
||||
return [s.summary() for s in rows]
|
||||
|
||||
|
||||
@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)
|
||||
if complete is not None:
|
||||
stmt = stmt.where(models.Sop.complete == complete)
|
||||
if 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()
|
||||
if not sop:
|
||||
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}")
|
||||
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)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
return sop.to_dict()
|
||||
|
||||
|
||||
@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)
|
||||
if not sop:
|
||||
raise HTTPException(status_code=404, detail="SOP not found")
|
||||
require_project_access(db, user, sop.project_id)
|
||||
db.delete(sop)
|
||||
db.commit()
|
||||
return {"deleted": sop_id}
|
||||
@@ -353,8 +452,11 @@ def delete_sop(sop_id: str, db: Session = Depends(get_db)):
|
||||
|
||||
# ── Work Packages ────────────────────────────────────────────────────────────
|
||||
@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
|
||||
if wp is not None:
|
||||
require_project_access(db, user, wp.project_id)
|
||||
if wp is None:
|
||||
wp = models.WorkPackage(id=body.id or gen_id("wp"))
|
||||
db.add(wp)
|
||||
@@ -378,6 +480,7 @@ def list_wps(
|
||||
sop_id: Optional[str] = Query(None),
|
||||
parent_id: Optional[str] = Query(None),
|
||||
status: Optional[str] = Query(None),
|
||||
user: models.User = Depends(auth.get_current_user),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
stmt = select(models.WorkPackage)
|
||||
@@ -389,12 +492,13 @@ def list_wps(
|
||||
stmt = stmt.where(models.WorkPackage.parent_id == parent_id)
|
||||
if 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()
|
||||
return [w.summary() for w in rows]
|
||||
|
||||
|
||||
@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
|
||||
from counts so a split package's hours aren't double-counted with its
|
||||
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)
|
||||
if 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()
|
||||
|
||||
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}")
|
||||
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)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
return wp.to_dict()
|
||||
|
||||
|
||||
@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)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
db.delete(wp)
|
||||
db.commit()
|
||||
return {"deleted": wp_id}
|
||||
|
||||
|
||||
@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
|
||||
open (the AWP release gate)."""
|
||||
wp = db.get(models.WorkPackage, wp_id)
|
||||
if not wp:
|
||||
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 []
|
||||
open_names = [c.get("name") for c in constraints if c.get("status") == "open"]
|
||||
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")
|
||||
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)
|
||||
if not wp:
|
||||
raise HTTPException(status_code=404, detail="Work Package not found")
|
||||
require_project_access(db, user, wp.project_id)
|
||||
wp.status = body.status
|
||||
if body.status == "Issued" and wp.issued_at is None:
|
||||
wp.issued_at = models.utcnow()
|
||||
|
||||
@@ -12,7 +12,7 @@ can upsert without round-tripping a sequence.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
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 .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):
|
||||
__tablename__ = "comments"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user