Fix the WP navigator and the squeezed embedded layout; add per-project permissions
Layout — the reported "skinny scrolling windows" - .content-area capped the whole suite at 1000px, so on a 1920 screen the embedded Work Package Creator ran in a ~930px column with its own scrollbar inside the page's. The wizard now caps at 1700px and the Creator/Dashboard tab goes full-bleed: the iframe fills the window below the app chrome and owns the only scrollbar. Needed `flex: none` on the content area — as a `flex: 1` item its flex-basis overrode `height`, leaving the used height indefinite so the child's `height: 100%` collapsed the iframe to its 150px default. - The SOP wizard's fields were one per row; they now flow into ~340px columns. Navigator — now an auto-hiding drawer - It was a fixed 262px column that stole width from the form AND was hidden below 1100px, so embedded (the normal path) it never appeared at all — that's the "broken side menu". It's now an overlay drawer behind a slim always-visible edge handle: hover or tap to open, move away / Escape / pick a package to close, or pin it to keep it open (pinned shifts the form and the page chrome across, and is remembered). A gutter keeps the handle off the section-nav chips. Bugs found while checking the site over - collectStepData() still read the SOP team fields as text inputs, but wave 1 made them account pickers — so it wrote a user ID into state.team.pm where the display NAME belongs, and the SOP would print `user_ab12…` as the PM. Now synced properly from the pickers. - loadSampleData() set .value on those selects with fictional names; setting an unmatched value on a <select> silently does nothing, so the sample lost its team. It now stores them as names without an account, which the picker shows as "(no account)". - My earlier CSS block replacement had deleted the SOP-chip, people-picker and critical-tag styles. Restored. Same picker everywhere the SOP names someone - Sign-off roles (step 3, required and optional) are account pickers now, storing userId alongside the name, so a signature belongs to an account that can be notified. Titles stay free text. Per-project permissions (asked for: "change project permissions for individual users") - project_members.role overrides the account's role on that project, so a PM on one job can be a Project User on another. Empty = inherit; app admin is admin everywhere. effective_role() feeds require_project_admin, so WP delete, completed- SOP edits and project delete are all judged per project. - Project access is now its own column in the admin console (it was buried among the action buttons, which is why it couldn't be found), showing the project count per account; the dialog sets access plus the role on each project. - The members endpoint reports each person's effective role on that project. Verified: 157 API checks across five suites on clean databases (44 permissions + 22 password reset + 34 search/localization + 39 gates/notifications + 18 new per-project permission checks), 16 drawer-behaviour + 4 pinned-mode UI checks driven in headless Chrome, and probes confirming the team/sign-off pickers populate and no longer corrupt state.team on step navigation. Screenshots reviewed at 1920x1080. Service-worker cache bumped to v3 so browsers pick up the new shell. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""per-project member role
|
||||
|
||||
Lets someone be Project Admin on one job and a normal Project User on another.
|
||||
Empty string means "inherit the account's own role" (users.role), which is exactly
|
||||
how every existing membership behaved, so this is a no-op for current data.
|
||||
|
||||
Revision ID: d15b8c4ef207
|
||||
Revises: c93f2b1d7e04
|
||||
Create Date: 2026-08-03 17:58:22.401118
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'd15b8c4ef207'
|
||||
down_revision = 'c93f2b1d7e04'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column('project_members', sa.Column('role', sa.String(length=20),
|
||||
nullable=False, server_default=''))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column('project_members', 'role')
|
||||
@@ -137,16 +137,36 @@ def require_project_access(db: Session, user: "models.User", project_id: Optiona
|
||||
raise HTTPException(status_code=403, detail="You don't have access to this project")
|
||||
|
||||
|
||||
def effective_role(db: Session, user: "models.User", project_id: Optional[str]) -> str:
|
||||
"""The user's permissions role ON THIS PROJECT.
|
||||
|
||||
An app admin is admin everywhere. Otherwise a membership row may carry its own
|
||||
role — so a PM on one job can be a plain Project User on another — and an empty
|
||||
membership role falls back to the account's own role."""
|
||||
if auth.is_admin(user):
|
||||
return auth.ROLE_ADMIN
|
||||
if project_id:
|
||||
row = db.scalars(
|
||||
select(models.ProjectMember).where(
|
||||
(models.ProjectMember.user_id == user.id)
|
||||
& (models.ProjectMember.project_id == project_id)
|
||||
)
|
||||
).first()
|
||||
if row and (row.role or "").strip():
|
||||
return auth.normalize_role(row.role)
|
||||
return auth.normalize_role(user.role)
|
||||
|
||||
|
||||
def require_project_admin(db: Session, user: "models.User", project_id: Optional[str],
|
||||
what: str = "this action") -> None:
|
||||
"""Destructive / baseline-changing operations: deleting a work package or a
|
||||
project, and editing a SOP that has already been completed. Requires project
|
||||
access AND the project_admin (or admin) permissions role."""
|
||||
access AND Project Admin *on that project*."""
|
||||
require_project_access(db, user, project_id)
|
||||
if not auth.is_project_admin(user):
|
||||
if effective_role(db, user, project_id) not in (auth.ROLE_ADMIN, auth.ROLE_PROJECT_ADMIN):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=f"{what} requires the Project Admin permissions role",
|
||||
detail=f"{what} requires the Project Admin role on this project",
|
||||
)
|
||||
|
||||
|
||||
@@ -356,6 +376,9 @@ class RoleIn(BaseModel):
|
||||
|
||||
class ProjectAssignIn(BaseModel):
|
||||
project_ids: list[str] = Field(default_factory=list)
|
||||
# Optional per-project permissions role, {project_id: role}. Omit or use '' to
|
||||
# inherit the account's own role on that project.
|
||||
roles: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
LOGIN_MAX_ATTEMPTS = int(os.getenv("AUTH_MAX_ATTEMPTS", "5"))
|
||||
@@ -711,11 +734,13 @@ def get_user_projects(user_id: str, _admin: models.User = Depends(auth.require_a
|
||||
u = db.get(models.User, user_id)
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
assigned = db.scalars(select(models.ProjectMember.project_id).where(models.ProjectMember.user_id == user_id)).all()
|
||||
rows = db.scalars(select(models.ProjectMember).where(models.ProjectMember.user_id == user_id)).all()
|
||||
projects = db.scalars(select(models.Project).order_by(models.Project.name)).all()
|
||||
return {
|
||||
"user": u.to_dict(),
|
||||
"assigned": list(assigned),
|
||||
"assigned": [r.project_id for r in rows],
|
||||
# Per-project role overrides, keyed by project id ('' = inherit the account's).
|
||||
"roles": {r.project_id: (r.role or "") for r in rows},
|
||||
"projects": [{"id": p.id, "name": p.name, "number": p.number} for p in projects],
|
||||
}
|
||||
|
||||
@@ -727,11 +752,19 @@ def set_user_projects(user_id: str, body: ProjectAssignIn, _admin: models.User =
|
||||
if not u:
|
||||
raise HTTPException(status_code=404, detail="User not found")
|
||||
valid = set(db.scalars(select(models.Project.id).where(models.Project.id.in_(body.project_ids))).all()) if body.project_ids else set()
|
||||
# Only the two project-scoped roles make sense here: app admin is global, and
|
||||
# anything unrecognised falls back to inheriting the account's own role.
|
||||
allowed = (auth.ROLE_PROJECT_ADMIN, auth.ROLE_PROJECT_USER)
|
||||
roles = {pid: r for pid, r in (body.roles or {}).items() if r in allowed}
|
||||
db.execute(delete(models.ProjectMember).where(models.ProjectMember.user_id == user_id))
|
||||
for pid in valid:
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid))
|
||||
db.add(models.ProjectMember(id=gen_id("pm"), user_id=user_id, project_id=pid,
|
||||
role=roles.get(pid, "")))
|
||||
log_event(db, _admin, "project_access_changed", "user", u.id, summary=u.username,
|
||||
detail={"projects": len(valid),
|
||||
"overrides": {p: r for p, r in roles.items() if p in valid}})
|
||||
db.commit()
|
||||
return {"assigned": sorted(valid)}
|
||||
return {"assigned": sorted(valid), "roles": {p: roles.get(p, "") for p in sorted(valid)}}
|
||||
|
||||
|
||||
# ── Projects ─────────────────────────────────────────────────────────────────
|
||||
@@ -1494,7 +1527,7 @@ def project_members(project_id: str, user: models.User = Depends(auth.get_curren
|
||||
seen.add(u.id)
|
||||
out.append({"id": u.id, "username": u.username, "full_name": u.full_name,
|
||||
"email": u.email, "project_role": u.project_role or "",
|
||||
"role": auth.normalize_role(u.role)})
|
||||
"role": effective_role(db, u, project_id)})
|
||||
out.sort(key=lambda x: (x["full_name"] or x["username"] or "").lower())
|
||||
return out
|
||||
|
||||
|
||||
@@ -168,9 +168,13 @@ class User(Base):
|
||||
|
||||
|
||||
class ProjectMember(Base):
|
||||
"""Which users may access which projects. A user sees/operates on a project
|
||||
only if a row links them to it (admins bypass this entirely). One row per
|
||||
(user, project) pair."""
|
||||
"""Which users may access which projects, and what they may do there. A user
|
||||
sees/operates on a project only if a row links them to it (admins bypass this
|
||||
entirely). One row per (user, project) pair.
|
||||
|
||||
`role` is the permissions role ON THIS PROJECT: someone can be Project Admin on
|
||||
one job and a normal Project User on another. Empty means "inherit the account's
|
||||
own role" (User.role), which is how every existing row behaves."""
|
||||
__tablename__ = "project_members"
|
||||
__table_args__ = (UniqueConstraint("user_id", "project_id", name="uq_project_member"),)
|
||||
|
||||
@@ -181,6 +185,7 @@ class ProjectMember(Base):
|
||||
project_id: Mapped[str] = mapped_column(
|
||||
String(40), ForeignKey("projects.id", ondelete="CASCADE"), index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(20), default="") # '' = inherit User.role
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=utcnow)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user