'; 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 = '
'+
+ '
Sessions
'+sessions.size+'
'+
+ '
Events
'+evs.length+'
'+
+ '
Range
'+fmt(first)+' → '+fmt(last)+'
';
+ html += '
Step views
Step
Views
';
+ for(let i=1;i<=10;i++) html += '
Step '+i+'
'+(byStep[i]||0)+'
';
+ html += '
';
+ html += '
Actions
Event
Count
';
+ Object.keys(byEvent).sort().forEach(k => html += '
'+uesc(k)+'
'+byEvent[k]+'
');
+ html += '
';
+ 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.
diff --git a/html/login.html b/html/login.html
index 2eebbac..e155089 100644
--- a/html/login.html
+++ b/html/login.html
@@ -76,7 +76,6 @@
- Prime Controls
Sign in
Work Package Suite
diff --git a/html/project-data.js b/html/project-data.js
index 10f285d..b6090b8 100644
--- a/html/project-data.js
+++ b/html/project-data.js
@@ -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
};
diff --git a/html/work-package-suite-app.js b/html/work-package-suite-app.js
index 69cf65a..205ea22 100644
--- a/html/work-package-suite-app.js
+++ b/html/work-package-suite-app.js
@@ -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; }
diff --git a/html/work-package-suite.html b/html/work-package-suite.html
index 9ec4568..9114c5b 100644
--- a/html/work-package-suite.html
+++ b/html/work-package-suite.html
@@ -23,10 +23,9 @@
-
-
-
-
+
+
+
1 / 10
@@ -34,13 +33,13 @@
@@ -298,7 +297,7 @@
-
+
@@ -329,9 +328,9 @@
-
-
-
+
+
+
@@ -342,9 +341,9 @@
-
📋 Work Package Creation
+
Work Package Creation
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.
-
+
@@ -357,12 +356,12 @@
- 💬 Step Comments
+ Feedback
-
-
+
+
@@ -370,8 +369,8 @@
-
-
+
+
diff --git a/server/app.py b/server/app.py
index 4453b66..eeb3753 100644
--- a/server/app.py
+++ b/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()
diff --git a/server/models.py b/server/models.py
index e4b20fc..a13a3d3 100644
--- a/server/models.py
+++ b/server/models.py
@@ -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"