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

@@ -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()